Skip to content

Scheduled work & tasks

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.

Three ways to schedule

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.

Quick start

Create a task from the CLI with atomic-agent task create. The --message is the prompt the agent will run when the task fires.

Terminal window
# 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))

Inspect and manage tasks:

Terminal window
atomic-agent task list # all tasks
atomic-agent task list --status pending --limit 20
atomic-agent task show <id> # one task, full detail
atomic-agent task cancel <id> # idempotent cancel

Getting the result: --notify telegram

A 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:

Terminal window
atomic-agent task create \
--message "Summarize my git activity from yesterday" \
--cron "0 9 * * 1-5" \
--tz "Europe/Berlin" \
--notify telegram

telegram 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:

StatusReports?Why
completedyesThe happy path, with the reply text.
failedyesRetry budget exhausted.
blockedyesPermanent failure or missing session.
retry (running → pending)noA retry is not an outcome yet — you’d get a message per attempt.
cancellednoOperator-initiated or shutdown-driven; you already know.

What the message contains

Every report is a plain-text DM to the paired owner carrying:

  • A status line✅ completed, ❌ failed, or ⛔ blocked.
  • The task’s prompt, flattened to one line and cut to 120 characters, so you can tell which job fired without opening anything.
  • The schedule kind and task idcron, interval, or one-shot.
  • Attempt count and durationAttempt 2/3 · took 1m 12s.
  • The outcome body. On 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.

How the scheduler works

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:

  • No pile-up. If a tick takes longer than tickMs, the next fire is skipped while the previous one is still draining. Ticks never stack.
  • Failures are contained. A bad task logs a warning and records an error metric, but never crashes the polling loop.
  • Cross-session parallelism, per-session FIFO. One tick can run tasks from many independent sessions at once, but tasks within a single session always run in order, serialized through the TurnController.
  • Single-use. Once the scheduler is stopped (at shutdown) it can’t be restarted in the same process.

Task lifecycle

Each task is a durable row in tasks.sqlite that moves through a small state machine.

  • A task is created pending (with a schedule, or null for immediate execution).
  • When drained, it’s claimed pending → running, the session is loaded (or created), and runtime.runTurn executes the message.
  • On success: one-shot tasks become terminal (completed); recurring tasks are requeued — attempts/errors/times reset and scheduled_for is rearmed to the next firing.
  • On a retryable failure: the task moves running → pending, a backoff delay is applied, and it retries until maxAttempts is exhausted, at which point it becomes failed.
  • On a permanent failure — or when the session can’t be loaded — the task skips retries entirely and becomes blocked.

failed vs blocked — two different endings

They are not two names for the same outcome. Only one of them is about the retry budget.

StatusWhy it happensWhat to do
failedThe 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.
blockedThe 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.

Sessions: lazy vs persistent

  • One-shot tasks created without a sessionId get a lazily-allocated ephemeral session at first execution.
  • Recurring tasks get one persistent session at create time, reused across every firing — there’s no row-per-firing duplication. If that session goes missing, recurring tasks auto-recreate it.

Stale recovery

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.

Letting the agent schedule its own work

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.

ToolWhat it does
tasks.schedulePersist and schedule a task (optionally in a new session).
tasks.cronPreview when a cron expression would fire.
tasks.listQuery tasks, optionally filtered by session/status.
tasks.showFetch one task.
tasks.cancelCancel 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.

Running tasks without the scheduler

For cron jobs, CI, or manual ops, you can drain due tasks once without starting the long-lived scheduler:

Terminal window
atomic-agent task tick --limit 50 # one-shot drain of everything due now
atomic-agent task run <id> # run a specific task
atomic-agent task run --all-pending # drain all pending tasks

Over HTTP (when running atomic-agent serve):

Terminal window
# Create a task — sessionId and userMessage are both required
curl -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, run
curl http://127.0.0.1:8787/api/tasks?status=pending
curl -X POST http://127.0.0.1:8787/api/tasks/<id>/run # explicit drain

Webhooks: events as scheduled work

Incoming 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.

Configuration

Scheduling is controlled by the tasks.* config block (in <stateDir>/config.json) and matching ATOMIC_AGENT_TASKS_* environment variables.

KeyDefaultPurpose
tasks.enabledtrueMaster kill switch for the whole task subsystem.
tasks.schedulerEnabledtrueWhether the background scheduler is constructed and started.
tasks.schedulerTickMs5000Polling interval, in milliseconds.
tasks.schedulerBatchMax tasks consumed per tick (more independent sessions can fire in parallel).
tasks.maxAttempts3Retry budget per task before it’s terminal.
tasks.backoffInitialMsFirst inter-attempt backoff delay.
tasks.backoffMaxMsBackoff ceiling.
tasks.runOnCreatetrueFire a drain immediately when a non-future task is created.
tasks.minIntervalMs1000Floor on --every, so a runaway interval can’t saturate the loop.
tasks.staleAfterMs300000Age after which a running row is recovered to pending on bootstrap.
tasks.agentToolsEnabledtrueWhether the agent’s tasks.* tools are registered.

Scheduling caveats

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.

Missed runs are not caught up

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.

Interval tasks drift forward

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.

Timezones apply to cron only

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.

Bounds enforced at create time

BoundValueApplies to
Minimum interval1,000 ms--every / interval
Maximum horizon10 years ahead--at
Cron expression length200 chars--cron
User message16,000 charsall 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.

Retry backoff is deterministic

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.

Stale recovery runs once, at startup

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.

Other limits

  • No scheduling over HTTP: 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.