Core Concepts
Schedules & Triggers

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:

  1. Bundle + upload — your code is packaged and shipped to the env's project. Each @agent function becomes (or updates) an agent in that project.
  2. Diff — the CLI harvests the @schedule / @trigger decorators off your deployed agents and compares them against what already exists on the server for the chosen env.
  3. Apply — creates, updates, and deletes are applied in order. The CLI prints the plan first; with --dry-run it 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.
  • timezone must 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 a TZ= or CRON_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 @agent

Identity 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 changeReconciler behavior
Add a new @schedule(cron=...)Create a new schedule
Remove a @schedule decoratorDelete that schedule
Edit the cron= valueDelete the old, create the new
Move a @schedule to another agentDelete 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.

outcomeMeans
firedA run was submitted. run_id names it.
skippedWe decided not to run this occurrence — see reason.
errorWe tried and something broke on our side.
missedThe occurrence passed with no attempt at all.

reason is a fixed token, never prose:

reasonMeaningWhat happens next
invalid_cron / invalid_timezoneThe expression or zone will not parseThe schedule disables itself; fix and re-enable
agent_not_foundThe agent is goneNext occurrence
no_deployed_versionThe agent has never been deployedRetried within the occurrence
plan_limitYour account was at its concurrency ceilingRetried within the occurrence
agent_lookup_failed / account_lookup_failed / dispatch_failedA fault on our sideRetried within the occurrence
overdueRides a missed outcome — nothing attempted this occurrenceDetected on recovery
run_never_startedRides a fired outcome — we submitted a run and no worker ever started itThe run is yours to replay or trigger by hand
manualYou 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.

CaseAlerts
Occurrence passed with no attempt (overdue)Yes
Permanently unrunnable — bad cron or timezone, agent deletedYes
Transient, and the next occurrence arrived before the retry succeededYes
Transient, still retrying inside the occurrenceNo
Fired, but no worker ever started the runYes

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:

HeaderEffect
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)

Featurev1Status
Schedule enabled=False flagUse remove-decorator + redeployDeferred
Trigger auth beyond HMACHMAC shared secret onlyDeferred
Per-env runtime overrides (budget)Stays on @agent decoratorDeferred — by design
Schedule input templatingStatic; parse time inside the functionDeferred

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.