Scheduling/Overview

Scheduling

A trigger is a record in the agent_triggers dataset of your agent space: a program spec, a schedule or event, arguments, and an owner naming the device that runs it. The running agent converges its scheduler on those records every tick, so creating, editing, pausing and moving a job is just writing a record — from the UI, from a program, or from curl.

The record syncs; the execution does not. A trigger fires only on a device where anyrt is running, and a stored trigger keeps no stopped device alive. The pinned device runs local work offline; a program that calls a provider needs that provider reachable.

Before you write a trigger

The server and anyrt serve are up (runtime quickstart); the runtime provisions the trigger store on boot. The program you name resolves from the working space or a configured overlay. The shell examples use $SPACE (the agent space id) and $CHAT (the target chat's object id). anyrt run --from-space runs the same program once by hand — the way to check its arguments before scheduling it.

A trigger record

{
  "name": "daily history rollup",
  "kind": "cron",
  "spec": {"cron": "0 8 * * *"},
  "program": "agent:rollup@v1",
  "args": {"space": "bao", "chatId": "<chatId>"},
  "owner": "",
  "enabled": true,
  "maxConsecutiveFailures": 3
}
Field Meaning
kind cron, once or event
spec {"cron": "<expr>"} or {"every_s": n} · {"at": <epoch seconds>} · {"dataset": "chat_messages", "objectId", "spaceId?", "filter?"}
program a program spec — agent:remind@v1, a connector, or one you authored
args the JSON passed to main(args)
owner the device pin — a peer id, or "" for unassigned (the active device claims it)
enabled pause without deleting (absent = true)
limits {fuelPerRun?, timeoutS?, maxCostPerRun?} — part of the definition, not enforced; every run gets the runtime's fixed budget (Limits)
maxConsecutiveFailures circuit-breaker threshold (default 3)

The runtime stamps three fields back onto the record — lastRunAt, lastStatus and consecutiveFailures — which is scheduler state, not history. Every run's history is a synced summary in the agent_runs dataset, keyed by triggerId; see Runs and monitoring.

Where the records live

Triggers sit on the trigger anchor: the bao/triggers/v1 child of the agent space's bao/v1 bundle, which anyrt serve provisions on boot with the hidden harness type agent_trigger. Resolve it by seed, never by name; the child route requires that type. The records live in a storage collection namespaced to the harness type (<typeId>_agent_triggers); read its name from the space's dataset listing:

TRG=$(curl -s "http://127.0.0.1:7001/v1/spaces/$SPACE/types?includeHidden=true" \
  | jq -er '.types[] | select(.xKey=="agent_trigger") | .id')
ANCHOR=$(curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/bundles/bao%2Fv1/children \
  -H 'Content-Type: application/json' \
  -d "{\"seed\": \"bao/triggers/v1\", \"type\": \"$TRG\"}" | jq -er .objectId)
TRIGGERS=$(curl -s http://127.0.0.1:7001/v1/spaces/$SPACE/datasets \
  | jq -r '.datasets[].name | select(endswith("_agent_triggers"))')

curl -s -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/upsert \
  -H 'Content-Type: application/json' \
  -d "{\"objectId\": \"$ANCHOR\", \"dataset\": \"$TRIGGERS\",
       \"records\": [{\"id\": \"daily-digest\", \"fields\": {
         \"name\": \"daily history rollup\", \"kind\": \"cron\", \"spec\": {\"cron\": \"0 8 * * *\"},
         \"program\": \"agent:rollup@v1\", \"args\": {\"space\": \"$SPACE\", \"chatId\": \"$CHAT\"},
         \"enabled\": true}}]}"

The record id is yours to choose (a slug). Guest code passes the store key instead — c.upsert_record(space, anchor, "agent_triggers", …) — and the any@v1 client resolves the collection. The owning device adopts the record within one 5-second tick.

This example schedules the shipped history rollup at 08:00 UTC. Adoption does not run it immediately: a cron arms its next future occurrence. Look for daily-digest in the trigger registry, then read its agent_runs summaries after it fires.

How a tick works

every 5 s, on each running device:
   reconcile ── read agent_triggers ──▶ adopt / claim / refresh / evict
   health    ── mark inert definitions (invalid_spec, unsupported_source)
   events    ── one chat watch per watched (space, chat)
   fire      ── due entries → run program → agent_runs summary + record stamp

The dataset is the source of truth. A registry-only edit would be reverted by the next reconcile, which is why the control-plane routes write through to the record.

Why it matters. There is no scheduler service. The schedule syncs with your data, encrypted, to every device you own; whichever device holds the pin fires it, and the run summary lands in the same space. A laptop without a network still fires its pinned jobs; shut it down and they pause, and repinning them to a machine that is running moves them within a tick.

Semantics in one table

cron once event
fires when now ≥ next occurrence, armed strictly forward from adoption now ≥ at and never ran a new message lands in the watched chat
missed while owner down does not exist — no catch-up burst fires late on the next tick does not exist — live only
after firing re-arms forward auto-disables, stays as its audit trail keeps watching
failure counts toward the breaker consumes the shot — no retry counts toward the breaker

Moving the active agent to another device does not move an already pinned user trigger. Change its owner to move it. For work that spans several executions, save a cursor or checkpoint in the database and resume it on the next run; each invocation still has the runtime's limits.