One-shot (--at)
Run once at a specific time (epoch milliseconds), 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 — "at", "every", or "cron" in the stored JSON — and it fires on a clock. Note the one-shot kind is the literal string "at"; there is no "one-shot" value, though that’s the word this page uses for the behavior.
One-shot (--at)
Run once at a specific time (epoch milliseconds), 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 epoch milliseconds)atomic-agent task create \ --message "Check the build status and summarize failures" \ --at 1718700000000
# Generate the timestamp rather than typing it:atomic-agent task create --message "..." --at $(($(date +%s) * 1000 + 3600000))# 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 cancel--notify telegramA scheduled task runs while you’re not watching, so by default its output just lands in the task row. Add --notify telegram and the runner reports the task’s final result to your paired Telegram chat when it finishes:
atomic-agent task create \ --message "Summarize my git activity from yesterday" \ --cron "0 9 * * 1-5" \ --tz "Europe/Berlin" \ --notify telegramtelegram is currently the only accepted value — anything else is rejected at create time. It requires Telegram to be configured and paired (see Local models for setup). The choice is stored on the task and shows up as a notify column in task list.
Failures notify too. This is the whole point of turning it on for an unattended job: a report is sent for completed, failed, and blocked. If your 9am summary stops arriving, you find out because a ⛔ blocked message arrives instead — not by noticing weeks later that nothing has run.
Reports are sent for exactly three statuses, and never for anything else:
| Status | Reports? | Why |
|---|---|---|
completed | yes | The happy path, with the reply text. |
failed | yes | Retry budget exhausted. |
blocked | yes | Permanent failure or missing session. |
retry (running → pending) | no | A retry is not an outcome yet — you’d get a message per attempt. |
cancelled | no | Operator-initiated or shutdown-driven; you already know. |
Every report is a plain-text DM to the paired owner carrying:
✅ completed, ❌ failed, or ⛔ blocked.cron, interval, or one-shot.Attempt 2/3 · took 1m 12s.completed, the agent’s final reply text, capped at 3,000 characters (or (no reply) when the turn ended without one). On failed or blocked, the error message capped at 500 characters, prefixed with its failure category — Error [tool]: ….Truncated excerpts always end in … [truncated], so a cut is never silent.
The 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"]
Outcome -->|permanent failure<br/>or missing session| Blocked["terminal: blocked<br/>(no retry, no recovery)"]
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.blocked.failed vs blocked — two different endingsThey are not two names for the same outcome. Only one of them is about the retry budget.
| Status | Why it happens | What to do |
|---|---|---|
failed | The retry budget ran out. Every attempt up to maxAttempts was tried and each one failed with a retryable (transport) error. | Usually transient — re-create the task once the underlying service is back. |
blocked | The failure was classified permanent (grammar or tool), or the task’s session could not be loaded or recreated (session_not_found: <id>). No retries are attempted at all. | Fix the cause first — recreate the session, or fix the prompt/tool — then re-create the task. |
Two consequences worth internalising:
blocked can happen on attempt 1. The failure classifier returns tool as its catch-all for any error it doesn’t recognise, so an unexpected exception is treated as permanent and blocks immediately — with maxAttempts untouched. A task showing blocked after one attempt is not a bug.blocked is terminal with no way out. It is one of the terminal statuses, and no CLI command, HTTP route, or agent tool transitions a task out of it. There is no retry, resume, or unblock. The only path forward is to create the task again — after recreating its session, if that was the cause.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 task — sessionId and userMessage are both requiredcurl -X POST http://127.0.0.1:8787/api/tasks \ -H "Authorization: Bearer $ATOMIC_AGENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"sessionId": "s-abc123", "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 | true | Fire a drain immediately when a non-future task is created. |
tasks.minIntervalMs | 1000 | Floor on --every, so a runaway interval can’t saturate the loop. |
tasks.staleAfterMs | 300000 | Age after which a running row is recovered to pending on bootstrap. |
tasks.agentToolsEnabled | true | Whether the agent’s tasks.* tools are registered. |
These are the behaviours people are most often surprised by. None of them are bugs — they follow from the scheduler being a simple due-poller over a durable queue — but they change how you write a schedule.
A task that is overdue fires once, no matter how many slots went by while the process was down. Ten missed hourly firings do not become ten runs; they become one. The store simply asks for rows whose scheduled_for is at or before now, and running the task rearms it.
If your job must not skip work, make each run self-correcting — “summarise everything since the last summary” — rather than assuming it fires exactly once per slot.
The next firing of a recurring task is computed from its completion time, not from the slot it was supposed to occupy. An interval schedule resolves to completedAt + everyMs, so every run pushes the next one further out by however long the run itself took.
A --every 3600 task whose turns take five minutes does not fire at :00 every hour — it fires roughly every 65 minutes, and the offset keeps growing. Over a day that is hours of drift.
tz is passed to the cron parser and nowhere else. An at schedule is an absolute epoch timestamp and an interval is a fixed number of milliseconds — neither has any notion of a calendar, so neither tracks DST. An interval job that lines up with 09:00 local time today will be an hour off after the clocks change; only cron with a tz holds the local hour.
| Bound | Value | Applies to |
|---|---|---|
| Minimum interval | 1,000 ms | --every / interval |
| Maximum horizon | 10 years ahead | --at |
| Cron expression length | 200 chars | --cron |
| User message | 16,000 chars | all tasks |
All of these are rejected up front, before anything is written to disk — a bad shape surfaces as a CLI exit code 1 or an HTTP 400, never as a silent failure inside a tick. The one exception is a malformed --at timestamp, which parses to a number instead of being rejected; see the warning above.
The delay before retry N is min(initialMs * 2^(attempt-1), maxMs) — 1,000 ms then 2,000, 4,000, 8,000, clipped at 60,000 ms by default. There is no jitter. If a shared dependency knocks out many tasks at once, they will all retry in lockstep rather than spreading out, so a flapping service can see synchronised bursts.
Backoff applies only to within-attempt retries. The gap between recurring firings comes from the schedule, not from backoff.
Rows stuck in running are reclaimed by a single pass on bootstrap: anything whose started_at is older than tasks.staleAfterMs (default 5 minutes) is flipped back to pending. There is no background sweeper.
The consequence: a task orphaned inside a still-running process is never reclaimed. If a turn hangs without throwing, its row sits in running for as long as the process lives — recovery only helps after a crash and restart.
POST /api/tasks takes no schedule field.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.