One-shot (--at)
Run once at a specific time, then stop. The task becomes terminal after it completes.
Sometimes you don’t want the agent to act now — you want it to act later, or every morning, or once an hour. Atomic Agent lets you schedule work: a message the agent will run on its own at a time you choose.
A scheduled job is just a normal turn with a clock attached. You give it a prompt (“summarize my git log and email me”), pick when it should fire (--at, --cron, or --every), and Atomic Agent persists it to disk. A single background timer wakes up, finds what’s due, and runs it — retrying automatically if something fails.
Everything stays local. Tasks live in a SQLite file in your state directory, and the scheduler is the only periodic timer in the runtime, so resource use stays predictable.
A task carries an optional schedule. Leave it off and the task runs as soon as it’s drained. Add one of three schedule kinds and it fires on a clock.
One-shot (--at)
Run once at a specific time, then stop. The task becomes terminal after it completes.
Interval (--every)
Run every N seconds, forever. Re-armed after each successful run.
Cron (--cron)
Run on a cron expression (e.g. 0 9 * * 1 — 9am every Monday), with optional timezone.
Create a task from the CLI with atomic-agent task create. The --message is the prompt the agent will run when the task fires.
# Run once, ~1 hour from now (--at takes a millisecond timestamp)atomic-agent task create \ --message "Check the build status and summarize failures" \ --at 1718700000000# Run every 3600 seconds (1 hour), foreveratomic-agent task create \ --message "Poll the deploy queue and flag anything stuck" \ --every 3600# Run at 09:00 every weekday, in a specific timezoneatomic-agent task create \ --message "Summarize my git activity from yesterday" \ --cron "0 9 * * 1-5" \ --tz "Europe/Berlin"Inspect and manage tasks:
atomic-agent task list # all tasksatomic-agent task list --status pending --limit 20atomic-agent task show <id> # one task, full detailatomic-agent task cancel <id> # idempotent cancelThe scheduler owns a single setInterval. Every tickMs (default 5000ms) it wakes, asks the task store for everything that’s due right now, and hands up to batch tasks to the runner.
flowchart TD
Tick["setInterval<br/>every tickMs (5s)"] --> Guard{running?}
Guard -->|yes| Skip["skip this tick<br/>(no pile-up)"]
Guard -->|no| Due["TaskRunner.runDue(now, batch)"]
Due --> Query["TaskStore.listDue(now)<br/>scheduled_for ≤ now"]
Query --> Group["group by session<br/>(per-session FIFO)"]
Group --> Run["run N tasks in parallel<br/>across sessions"]
Run --> Turn["runtime.runTurn<br/>(through TurnController)"]
Turn --> Outcome{outcome}
Outcome -->|success + recurring| Requeue["requeue: reset attempts,<br/>compute next scheduled_for"]
Outcome -->|success + one-shot| Done["terminal: completed"]
Outcome -->|retryable failure| Backoff["backoff, mark pending,<br/>retry within budget"]
Outcome -->|budget exhausted| Failed["terminal: failed / blocked"]
Requeue --> NextTick["picked up on a later tick"]
Run -->|error| Log["log warning,<br/>record metric, never crash"]
A few properties that fall out of this design:
tickMs, the next fire is skipped while the previous one is still draining. Ticks never stack.TurnController.Each task is a durable row in tasks.sqlite that moves through a small state machine.
runtime.runTurn executes the message.completed); recurring tasks are requeued — attempts/errors/times reset and scheduled_for is rearmed to the next firing.maxAttempts is exhausted, at which point it becomes failed or blocked.sessionId get a lazily-allocated ephemeral session at first execution.If the process crashes mid-task, rows stuck in running would otherwise be orphaned. On bootstrap, the task store flips any running row older than tasks.staleAfterMs back to pending so it gets picked up again.
When task tools are enabled, the agent can schedule follow-ups for itself mid-conversation — “remind me in an hour”, “check this every morning”. These are the same durable tasks, created from inside a turn.
| Tool | What it does |
|---|---|
tasks.schedule | Persist and schedule a task (optionally in a new session). |
tasks.cron | Preview when a cron expression would fire. |
tasks.list | Query tasks, optionally filtered by session/status. |
tasks.show | Fetch one task. |
tasks.cancel | Cancel a task (idempotent). |
These tools are gated by tasks.agentToolsEnabled. When that’s false, they don’t appear in the registry at all — the agent can’t see or call them.
For cron jobs, CI, or manual ops, you can drain due tasks once without starting the long-lived scheduler:
atomic-agent task tick --limit 50 # one-shot drain of everything due nowatomic-agent task run <id> # run a specific taskatomic-agent task run --all-pending # drain all pending tasksOver HTTP (when running atomic-agent serve):
# Create a taskcurl -X POST http://127.0.0.1:8787/api/tasks \ -H "Authorization: Bearer $ATOMIC_AGENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"userMessage": "Summarize today’s commits", "maxSteps": 15}'
# List, inspect, cancel, runcurl http://127.0.0.1:8787/api/tasks?status=pendingcurl -X POST http://127.0.0.1:8787/api/tasks/<id>/run # explicit drainIncoming webhooks become tasks too. A POST /api/webhooks/{name} doesn’t call runTurn directly — it renders the payload against the webhook’s userMessageTemplate and materializes a task via the task runner. That keeps webhook-triggered work on the same per-session FIFO and retry path as everything else.
Scheduling is controlled by the tasks.* config block (in <stateDir>/config.json) and matching ATOMIC_AGENT_TASKS_* environment variables.
| Key | Default | Purpose |
|---|---|---|
tasks.enabled | true | Master kill switch for the whole task subsystem. |
tasks.schedulerEnabled | true | Whether the background scheduler is constructed and started. |
tasks.schedulerTickMs | 5000 | Polling interval, in milliseconds. |
tasks.schedulerBatch | — | Max tasks consumed per tick (more independent sessions can fire in parallel). |
tasks.maxAttempts | 3 | Retry budget per task before it’s terminal. |
tasks.backoffInitialMs | — | First inter-attempt backoff delay. |
tasks.backoffMaxMs | — | Backoff ceiling. |
tasks.runOnCreate | false | Fire a drain immediately when a non-future task is created. |
tasks.staleAfterMs | — | Age after which a running row is recovered to pending on bootstrap. |
tasks.agentToolsEnabled | — | Whether the agent’s tasks.* tools are registered. |
--at horizon: schedules more than 10 years in the future are rejected.scheduled_for, not backoff.task tick doesn’t persist scheduling: it drains what’s due but is a debugging/ops tool, not a replacement for the long-lived scheduler.Tasks reference
Full task store lifecycle, status transitions, and HTTP/CLI surfaces.
Configuration
Every tasks.* key and ATOMIC_AGENT_TASKS_* env var.
HTTP API
POST /api/tasks, /api/tasks/:id/run, /api/tasks/drain, and webhooks.
Architecture
How the scheduler, task runner, and TurnController fit together.