Schedules & Triggers
Papayya wires automated starts — cron schedules and HTTP triggers — in code, as decorators stacked above your @agent function. papayya deploy harvests those decorators, diffs them against the server, and applies the changes. (You can also inspect and manage each imperatively — papayya schedules … and papayya triggers … — but the decorators in your code stay the declarative source of truth.)
Whichever fires, each fire is one run, and every run processes its items. For when to pick a schedule vs. a trigger vs. an on-demand API call, see Triggers.
How reconciliation works
Each papayya deploy --env <name> runs in three phases:
- Bundle + upload — your code is packaged and shipped to the env's project. Each
@agentfunction becomes (or updates) an agent in that project. - Diff — the CLI harvests the
@schedule/@triggerdecorators off your deployed agents and compares them against what already exists on the server for the chosen env. - Apply — creates, updates, and deletes are applied in order. The CLI prints the plan first; with
--dry-runit stops there.
An agent with no @schedule / @trigger decorators just bundles + uploads and skips reconciliation entirely.
The env is selected by --env <name> (or PAPAYYA_ENV), resolving the API key and project from ~/.papayya/config.json — there is no project file to declare envs in. See Environments.
Schedules
Stack one @schedule per cron above the agent it starts:
from papayya import agent, schedule
@schedule(cron="0 * * * *") # hourly, on the hour
@schedule(cron="30 9 * * 1-5") # weekdays at 9:30am UTC
@agent(name="ops-bot", budget_usd=2.00)
def ops_bot(run, payload):
...Each decorator is one schedule, and each fire mints one run. Standard 5-field cron (minute, hour, day-of-month, month, day-of-week). Cron expressions are validated at decoration time — an invalid expression raises DecoratorValidationError before deploy ever runs. Multiple @schedule decorators stack additively; a duplicate cron on the same agent is rejected at deploy.
Times default to UTC. @schedule also takes a timezone (IANA name, e.g. timezone="America/New_York"), validated at decoration time:
@schedule(cron="30 9 * * 1-5", timezone="America/New_York")
@agent(name="ops-bot")
def ops_bot(run, payload):
...The cron is evaluated in that zone, wall-clock. "30 9 * * 1-5" in America/New_York fires at 09:30 local year-round — 13:30 UTC in summer, 14:30 UTC in winter — and the shift across a DST boundary is handled for you.
Edges worth knowing:
- A wall-clock time that does not exist is skipped, not shifted. On the spring-forward day, 02:00–02:59 local never happens, so
"30 2 * * *"does not fire that day. It resumes the next day. If the run must happen daily, pick an hour outside the transition window. Falling back does not double-fire — the duplicated hour is served once. timezonemust be an IANA zone name."Local"is rejected: it would resolve to whatever zone the server process happens to run in, so the firing time would change when we redeploy with nothing in your schedule to explain it.- The zone goes in
timezone, not in the expression. A cron carrying aTZ=orCRON_TZ=prefix is rejected rather than honoured, so you never end up with two fields claiming to control the firing time and only one of them winning.
Changing timezone (or cron) on an existing schedule recomputes the next occurrence immediately; the new zone takes effect on the very next fire, not the one after.
Decorator order matters
@schedule (and @trigger) must sit above @agent — they attach to the registration @agent creates. Flip the order and you get:
DecoratorTargetError: @schedule / @trigger must be applied ABOVE @agentIdentity and rename semantics
Schedules are keyed by (agent, cron). Editing the cron expression is treated as delete + create, not update. This is intentional — there is no schedule ID stable across cron rewrites in v1.
| Code change | Reconciler behavior |
|---|---|
Add a new @schedule(cron=...) | Create a new schedule |
Remove a @schedule decorator | Delete that schedule |
Edit the cron= value | Delete the old, create the new |
Move a @schedule to another agent | Delete from old agent, create on new |
Disabling a schedule
Remove or comment out the @schedule decorator and redeploy:
# @schedule(cron="0 * * * *") # paused for the holidays
@agent(name="ops-bot")
def ops_bot(run, payload):
...A removed decorator is invisible to the harvester, so the reconciler deletes the schedule on the next deploy. Re-add the line to recreate it. (Out of band, papayya schedules disable <id> / papayya schedules enable <id> pause a schedule without touching code, but the next papayya deploy reconciles back to whatever the decorators declare.)
What schedules don't control
The agent's input, budget and step ceiling live on the @agent decorator, alongside the schedule. A schedule only decides when the run fires.
from papayya import agent, schedule
@schedule(cron="0 9 * * *")
@agent(name="ops-bot", budget_usd=2.00, max_steps=50)
def ops_bot(run, payload):
# All scheduled runs share this input shape — wire it from your code.
...If you need different inputs per fire (e.g. yesterday's date), parse the current time inside the function — the scheduler does not interpolate variables for you.
max_steps is not a per-schedule setting. It is read from the deployed bundle at run time, so it belongs on @agent. (The schedule API used to accept a max_steps field and store it without ever applying it; that field is now rejected rather than silently ignored.)
Did it actually run?
A schedule that stops firing is the failure you find out about days late, so every occurrence gets an item — whether or not a run came out of it.
The occurrence log
GET /v1/schedules/{id}/events returns one row per occurrence, newest first. Each schedule's most recent one also rides along on the schedule itself as last_event, so a list view can show the outcome without a second call.
{
"scheduled_for": "2026-08-06T09:00:00Z",
"occurred_at": "2026-08-06T09:00:02Z",
"outcome": "skipped",
"reason": "no_deployed_version",
"run_id": null
}scheduled_for is the occurrence — the wall-clock moment your cron named. occurred_at is when we acted on it. They differ by a couple of seconds normally, and by more when an occurrence was retried.
outcome | Means |
|---|---|
fired | A run was submitted. run_id names it. |
skipped | We decided not to run this occurrence — see reason. |
error | We tried and something broke on our side. |
missed | The occurrence passed with no attempt at all. |
reason is a fixed token, never prose:
reason | Meaning | What happens next |
|---|---|---|
invalid_cron / invalid_timezone | The expression or zone will not parse | The schedule disables itself; fix and re-enable |
agent_not_found | The agent is gone | Next occurrence |
no_deployed_version | The agent has never been deployed | Retried within the occurrence |
plan_limit | Your account was at its concurrency ceiling | Retried within the occurrence |
agent_lookup_failed / account_lookup_failed / dispatch_failed | A fault on our side | Retried within the occurrence |
overdue | Rides a missed outcome — nothing attempted this occurrence | Detected on recovery |
run_never_started | Rides a fired outcome — we submitted a run and no worker ever started it | The run is yours to replay or trigger by hand |
manual | You fired it yourself with Run now | — |
A transient failure retries inside the same occurrence with a backoff, rather than giving the occurrence up. A 200ms database blip does not delete your 09:00 report; it delays it by a minute.
last_run_at means a run was submitted
It moves only on a fired outcome (and on a manual trigger, which is also a run). It does not move on a tick that skipped, errored, or found nothing to do. If last_run_at says 09:00, something was dispatched at 09:00.
run_never_started is the one case where a run was dispatched and still produced no work — the schedule fired, but no worker ever picked the run up. That is why it is called out separately instead of being folded into fired.
The schedule-missed alert
One signal covers every way a scheduled occurrence fails to produce work — the cron didn't fire, we skipped it, or it fired and no worker started it — with the reason token in the payload. It is on by default (no threshold to configure) and can be muted per agent in the dashboard's alert rules. Repeat alerts for the same schedule are collapsed to one per 24 hours, so a schedule failing every five minutes sends one alert, not 288.
| Case | Alerts |
|---|---|
Occurrence passed with no attempt (overdue) | Yes |
| Permanently unrunnable — bad cron or timezone, agent deleted | Yes |
| Transient, and the next occurrence arrived before the retry succeeded | Yes |
| Transient, still retrying inside the occurrence | No |
| Fired, but no worker ever started the run | Yes |
The silent row is deliberate: a blip the retry resolves sixty seconds later cost you nothing.
Run now
POST /v1/schedules/{id}/trigger fires the schedule by hand, using its own input, budget and deployed version — which is the point, versus re-entering all three on a one-off POST /v1/runs and firing something subtly different from the thing that failed. It works on a disabled schedule too, so you can keep the daily report going while you fix a broken cron expression.
Why a schedule is off
enabled: false has two causes and they read differently: disabled_reason is "operator" when a human turned it off, and the failing reason token (invalid_cron, invalid_timezone) when the scheduler disabled it itself.
Triggers
Stack @trigger above the agent it starts:
from papayya import agent, trigger
@trigger(name="github-push", secret_env="GITHUB_WEBHOOK_SECRET")
@trigger(name="gitlab-mr", secret_env="GITLAB_WEBHOOK_SECRET")
@agent(name="pr-reviewer", budget_usd=2.00)
def pr_reviewer(run, payload):
...Each decorator creates a trigger with a stable URL: POST /v1/webhooks/{webhookId}/trigger. When an external system POSTs to it, the trigger fires and starts one run; the request body becomes that run's input. (The wire path and id prefix are frozen at webhooks / whk_; the product noun is the trigger.)
name must match ^[a-zA-Z0-9_-]{1,64}$ and secret_env must look like a real env var (^[A-Z][A-Z0-9_]*$) — both validated at decoration time, so secret_env="my-secret" is caught before deploy. The URL is server-assigned; the decorator takes no url kwarg.
Identity and rename semantics
Triggers are keyed by (agent, name). The URL is stable as long as name stays the same — even across redeploys with code changes. Renaming a trigger rotates the URL: the old trigger is deleted, a new one is created, and the CLI warns loudly:
⚠ trigger renamed pr-reviewer/old-name → pr-reviewer/new-name
This rotates the URL. Update the sender (GitHub/Stripe/etc.) before
the next event fires, or events will 404.The secret_env field
secret_env="FOO" names the process env var on the sending side that holds the HMAC bearer secret. The server generates the secret on create and the CLI prints it once:
✓ trigger created pr-reviewer/github-push
URL: https://api.getpapayya.com/v1/webhooks/whk_abc123/trigger
Secret: whk_secret_a8f3... (only shown now — store as $GITHUB_WEBHOOK_SECRET)Store the secret in your sender's environment under that name. The sending system signs requests with it; Papayya verifies the signature on every fire.
On subsequent deploys, the CLI checks whether $GITHUB_WEBHOOK_SECRET is set in your shell and warns (without blocking) if it isn't — that's a hint your sender will reject events.
Firing a trigger
curl -X POST https://api.getpapayya.com/v1/webhooks/whk_abc123/trigger \
-H "Authorization: Bearer $GITHUB_WEBHOOK_SECRET" \
-d '{"event": "push", "repo": "papayya", "branch": "main"}'Optional headers:
| Header | Effect |
|---|---|
X-Callback-URL: https://... | Papayya POSTs the result to this URL when the run finishes |
X-Idempotency-Key: <key> | Dedupes duplicate fires within a 24h window |
v1 limits (and what's deferred)
| Feature | v1 | Status |
|---|---|---|
Schedule enabled=False flag | Use remove-decorator + redeploy | Deferred |
| Trigger auth beyond HMAC | HMAC shared secret only | Deferred |
| Per-env runtime overrides (budget) | Stays on @agent decorator | Deferred — by design |
| Schedule input templating | Static; parse time inside the function | Deferred |
Troubleshooting
DecoratorTargetError on deploy. @schedule / @trigger must be stacked above @agent — they attach to the registration @agent creates. Put the automation decorators first, @agent last.
Reconciler complains the agent slug doesn't exist. The slug is your @agent(name="...") lowercased with spaces turned to dashes. If a schedule or trigger can't find its agent, the @agent decorator is missing, misnamed, or wasn't imported into the deployed file.
Trigger secret got lost. Remove the @trigger + redeploy to delete it, then re-add and redeploy to get a fresh secret. The plaintext is only printed at create time.
Schedule fires at the wrong time. Double-check UTC vs. local. 0 9 * * * with no timezone= is 9am UTC, which is 4am Eastern in winter and 5am Eastern in summer. Set timezone="America/New_York" for wall-clock local scheduling.
Schedule didn't fire at all. Read the occurrence log — GET /v1/schedules/{id}/events, or the outcome chip on the schedules page. It says which occurrence, what we did, and why. "Last Run" alone cannot answer it: one field describes the most recent tick, and the question is about a specific occurrence.
Schedule fired but nothing happened. Look for reason: "run_never_started" on a fired event. The run was submitted and no worker ever picked it up; the event carries the run_id.
Multiple envs, deploy errors with "pass --env". Pick one explicitly: papayya deploy --env prod. Envs are resolved from ~/.papayya/config.json, not a project file — see Environments.