One-shot (--at)
Run once at a specific time (epoch milliseconds), then stop. From the CLI this is currently broken in v0.6.5: see the known issue below.
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.
The task queue is a SQLite file in your state directory, and one scheduler timer polls it. Scheduled tasks fire only while an Atomic Agent process is running (the TUI or atomic-agent serve, for example); there is no separate background daemon, and runs missed while nothing was running fire once on the next start. A scheduled turn uses whichever model provider is active, like any other turn.
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", "interval" from --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. From the CLI this is currently broken in v0.6.5: see the known issue below.
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.
# Intended: run once, ~1 hour from now (--at takes epoch milliseconds)atomic-agent task create \ --message "Check the build status and summarize failures" \ --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 the Telegram guide for setup). The choice is stored on the task (notify in the task show JSON).
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 tick (ATOMIC_AGENT_TASKS_SCHEDULER_TICK_MS, default 5000ms) it wakes, asks the task store for everything that’s due right now, and hands up to ATOMIC_AGENT_TASKS_SCHEDULER_BATCH tasks (default 10) 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 ATOMIC_AGENT_TASKS_STALE_AFTER_MS 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 | Schedule a one-shot task: { userMessage, at?, inSeconds?, newSession?, notify? } (at in epoch ms, or inSeconds from now). Inherits the current session unless newSession is set. |
tasks.cron | Create a recurring task from a 5- or 6-field cron expression: { userMessage, expression, tz?, notify? }. Each firing runs in its own persistent session, not the caller’s. |
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 the ATOMIC_AGENT_TASKS_AGENT_TOOLS_ENABLED environment variable (default true). When it’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 (filters: ?session=, ?status=, ?limit=), inspect, cancel, runcurl -H "Authorization: Bearer $ATOMIC_AGENT_API_KEY" "http://127.0.0.1:8787/api/tasks?status=pending"curl -H "Authorization: Bearer $ATOMIC_AGENT_API_KEY" http://127.0.0.1:8787/api/tasks/<id>curl -X DELETE -H "Authorization: Bearer $ATOMIC_AGENT_API_KEY" http://127.0.0.1:8787/api/tasks/<id>curl -X POST -H "Authorization: Bearer $ATOMIC_AGENT_API_KEY" 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. A webhook binding in config.json can also carry a schedule, which makes the tasks it creates recurring.
Scheduling is configured only with ATOMIC_AGENT_TASKS_* environment variables (in your shell or <stateDir>/.env). There is no tasks block in config.json; one written there is ignored.
| Variable | Default | Purpose |
|---|---|---|
ATOMIC_AGENT_TASKS_ENABLED | true | Master kill switch for the whole task subsystem. |
ATOMIC_AGENT_TASKS_SCHEDULER_ENABLED | true | Whether the background scheduler is constructed and started. |
ATOMIC_AGENT_TASKS_SCHEDULER_TICK_MS | 5000 | Polling interval, in milliseconds. |
ATOMIC_AGENT_TASKS_SCHEDULER_BATCH | 10 | Max tasks consumed per tick (more independent sessions can fire in parallel). |
ATOMIC_AGENT_TASKS_MAX_ATTEMPTS | 3 | Retry budget per task before it’s terminal. |
ATOMIC_AGENT_TASKS_BACKOFF_INITIAL_MS | 1000 | First inter-attempt backoff delay. |
ATOMIC_AGENT_TASKS_BACKOFF_MAX_MS | 60000 | Backoff ceiling. |
ATOMIC_AGENT_TASKS_RUN_ON_CREATE | true | Drain immediately when a task that is already due is created through the task runner (HTTP, webhooks, agent tools). |
ATOMIC_AGENT_TASKS_STALE_AFTER_MS | 300000 | Age after which a running row is recovered to pending on bootstrap. |
ATOMIC_AGENT_TASKS_AGENT_TOOLS_ENABLED | true | Whether the agent’s tasks.* tools are registered. |
ATOMIC_AGENT_TASKS_MIN_INTERVAL_MS is parsed but not enforced in v0.6.5; the effective floor on --every is a fixed 1,000 ms.
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 exception in v0.6.5 is the CLI’s --at, which skips these checks and fires on the next tick; see the known issue 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 ATOMIC_AGENT_TASKS_STALE_AFTER_MS (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: it takes no schedule field (webhooks can carry one).atomic-agent import can bring enabled cron jobs over from Hermes and OpenClaw as native tasks. See Import.Tasks reference
Full task store lifecycle, status transitions, and HTTP/CLI surfaces.