messages_in table with kind='task' and a process_after timestamp — same inbox, same flow as any chat message. The agent is the entire scheduling interface: you create, change, and cancel tasks by asking it in chat. This tutorial assumes a working install with a wired agent (see your first agent).
Schedule a task in chat
Ask your agent, in whatever chat it’s wired to:Mechanically, the agent calls the
schedule_task MCP tool with a prompt, a processAfter timestamp for the first run, and a cron recurrence (0 9 * * 1-5). The container can’t write the host-owned inbound database, so the tool emits a system action through messages_out; the host applies it, inserting a messages_in row with kind='task', status pending, your process_after, the cron expression, and series_id set to the task’s own id. See the session DB schema for the columns.The agent confirms with the generated id (task-<timestamp>-<random>). For a one-shot task — “remind me tomorrow at 2pm to review the budget” — it simply omits recurrence.Know what fires it
Nothing watches the clock per-task. The host sweep visits every active session every 60 seconds, counts pending rows with
process_after <= now, and wakes the session’s container if one isn’t already running. The agent’s poll loop then picks the task up like any inbound message and executes the prompt.Two consequences worth internalizing:- ±60 seconds is the precision floor. A task due at 09:00:00 fires whenever the next sweep tick lands, plus container cold-start if nothing is warm. Don’t schedule anything that needs second-level timing.
- Recurring tasks are clones, not loops. When a recurring occurrence completes, the next sweep tick computes the next run with cron-parser, inserts a fresh
pendingrow carrying the sameseries_idforward, and clearsrecurrenceon the completed row so it isn’t re-cloned. The series id is the stable handle; individual occurrence ids come and go.
Manage it in chat
All management goes through the agent too:Exact tool behaviors behind those phrases:
Full parameter schemas are in the MCP tools reference.
| Tool | Behavior |
|---|---|
list_tasks | One row per series — the live pending or paused occurrence, not the pile of completed firings. The id shown is the series id, which every other tool expects. |
update_task | Edits the live occurrence in place (matches by id or series id). Omitted fields are untouched; an empty string clears recurrence (the task becomes one-shot) or script. If nothing matches, the host messages the agent back so it can tell you. |
pause_task / resume_task | Flip status between pending and paused. Paused rows are never counted as due, so they don’t fire or wake anything. |
cancel_task | Marks all live rows in the series completed and nulls recurrence — the series ends, history rows stay. |
Gate frequent tasks with a script
Every firing that reaches the agent is a Claude API call. For tasks that run more than a few times a day, The contract, from
schedule_task accepts an optional script — a bash script that runs before the agent is invoked:container/agent-runner/src/scheduling/task-script.ts:- When the task fires, the script runs first inside the container — 30-second timeout, 1 MB output cap.
- The last line of stdout must be JSON:
{ "wakeAgent": true|false, "data": ... }. wakeAgent: false— or any script error, timeout, or invalid output — marks the occurrence completed without invoking the agent. A recurring series still continues: the sweep clones the next occurrence from the completed row.wakeAgent: true— the agent wakes withdatainjected into the task prompt asscriptOutput.
Set the timezone
When you say “9am”, three layers have to agree on whose 9am. The chain: the host resolves Restart the host after changing it; the value is read once at startup.
TIMEZONE once at startup — process.env.TZ, then the .env file’s TZ, then the system locale, falling back to UTC, each candidate validated as a real IANA identifier (src/config.ts). Every container is spawned with -e TZ=<that value> (src/container-runner.ts), and the agent-runner’s timezone.ts resolves the same way inside.The result: naive timestamps the agent passes (2026-06-11T09:00:00, no offset) and cron expressions — both at schedule time and when the host sweep computes the next recurrence — are all interpreted in that one zone. To pin it:Inspect from the host
There is no then read its inbound database (host-owned — query read-only, never write to it):A healthy recurring series shows one
ncl tasks resource — the agent is the interface, and the rows live in each session’s inbound database, not the central DB. To look at them directly, find the session:pending row (the next run) plus one completed row per past firing, all sharing a series_id.What you built
A recurring task that is nothing but data: amessages_in row the 60-second host sweep wakes a container for, a clone-on-completion loop keyed by series_id, and an optional script gate between “the task fired” and “Claude got called”. No daemon, no crontab — delete the row series (via cancel_task) and it’s gone.
Next steps
- MCP tools reference — exact parameters for all six scheduling tools
- Session DB schema — every column on
messages_in - Multi-agent swarm — schedule work that fans out to other agents