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

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 a millisecond timestamp)
atomic-agent task create \
--message "Check the build status and summarize failures" \
--at 1718700000000

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

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

  • 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 or blocked.

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
curl -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, 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.runOnCreatefalseFire a drain immediately when a non-future task is created.
tasks.staleAfterMsAge after which a running row is recovered to pending on bootstrap.
tasks.agentToolsEnabledWhether the agent’s tasks.* tools are registered.

Limits worth knowing

  • User message cap: 16,000 chars — longer prompts are rejected at create time.
  • --at horizon: schedules more than 10 years in the future are rejected.
  • Backoff vs. firing delay: backoff applies only to within-attempt retries. The gap between recurring firings is controlled by the schedule’s 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.