Daemon
afk daemon runs Agent AFK as a long-running headless process that fires scheduled tasks on cron expressions or session-start triggers.
afk daemon runs Agent AFK as a long-running headless process. It fires scheduled tasks on
cron expressions, on session start, or from a pull queue — and optionally pushes completion
notifications via Telegram.
afk daemon --cron "0 */6 * * *" --task "/review --auto"Trigger modes
The daemon supports four trigger modes set with --trigger:
| Mode | Description |
|---|---|
cron | Fire on a cron expression (requires --cron) |
sessionstart | Fire once when the daemon starts |
both | Fire on sessionstart and then on the cron schedule |
pull | Dequeue tasks from a file-based queue (no cron expression needed) |
Pull mode
--trigger pull starts the daemon without a cron expression. Instead of firing on a schedule,
it polls ~/.afk/state/queue/ every 30 seconds and dequeues tasks written there by afk queue add.
afk daemon --trigger pullNo --cron or --task flag is needed. Tasks are consumed one-by-one in the order they were
enqueued. Use pull mode when you want to push work to a running daemon on demand rather than
on a fixed schedule.
Common options
afk daemon \
--cron "0 9 * * 1-5" \ # every weekday at 9am
--task "/review --auto" \ # command to run
--task-id nightly-review \ # human-readable task ID
--trigger cron # default when --cron is set| Flag | Description |
|---|---|
--cron <expr> | 5-field cron expression. Required when trigger includes cron. |
--task <command> | Command to fire on each tick |
--task-id <id> | Task identifier (used in telemetry and notifications) |
--trigger <mode> | cron | sessionstart | both | pull |
--once | Fire one tick and exit (useful for testing) |
--timeout-ms <ms> | Per-tick session timeout. Overrides AFK_TIMEOUT_MS. |
--thinking <mode> | Extended thinking: adaptive | disabled | max | enabled:<N> |
--effort <level> | Effort level: low | medium | high | xhigh | max |
--sessionstart-cooldown-ms <ms> | Cooldown between sessionstart fires |
--port <n> | HTTP port for live schedule sync (default 7777) |
--host <address> | Bind address for the control HTTP surface (default 127.0.0.1, loopback only). Overrides AFK_DAEMON_HOST. The control surface is unauthenticated — bind a non-loopback address (e.g. 0.0.0.0) only on a trusted or firewalled network. |
--briefs-dir <path> | Override the directory scanned for pending briefs (default ~/.afk/agent-framework/briefs). |
Testing a single tick
Use --once to fire one tick and exit. Useful to verify a command works before committing to
a long-running process:
afk daemon --cron "* * * * *" --task "/diagnose --auto" --onceThe daemon prints the telemetry record as JSON and exits with 0 on success or 1 on failure.
Telegram notifications
When TELEGRAM_BOT_TOKEN and AFK_TELEGRAM_ALLOWED_CHAT_IDS are configured, the daemon pushes
a notification to your Telegram chat on every task completion. The message includes the task ID,
status (✅ success / ⏭️ skipped / ❌ failed), duration, and a response excerpt.
export TELEGRAM_BOT_TOKEN=1234567890:ABC...
export AFK_TELEGRAM_ALLOWED_CHAT_IDS=12345678
afk daemon --cron "0 2 * * *" --task "run the nightly test suite and triage any failures"Scheduled tasks with afk schedule
afk schedule manages a persisted schedule stored in ~/.afk/config/schedules.json. Tasks
registered here are automatically loaded every time the daemon starts — no need to pass them
as flags each time.
Add a task
afk schedule add \
--name "Daily review" \
--command "/review --auto" \
--cron "0 9 * * 1-5" \
--trigger cron \
--notify failure| Flag | Required | Description |
|---|---|---|
--name <label> | Yes | Human-readable label |
--command <cmd> | Yes | Command to run on each tick |
--cron <expr> | Yes | 5-field cron expression |
--trigger <mode> | No | cron | sessionstart | both (default: cron) |
--notify <when> | No | failure | always | never (default: failure) |
--disabled | No | Add in disabled state |
List tasks
afk schedule listPrints a table of all scheduled tasks with their ID, name, cron, and enabled state.
Manage task lifecycle
afk schedule enable <id> # re-enable a disabled task
afk schedule disable <id> # disable without deleting
afk schedule remove <id> # permanently deleteView execution history
afk schedule logs <id> # last 10 runs (default)
afk schedule logs <id> -n 25 # last 25 runsReturns JSON telemetry records in chronological order.
Live sync
Write operations (add, enable, disable, remove) attempt to live-sync the change to a
running daemon via its control port. If the daemon isn't running, the change is saved to disk
and will take effect on next start.
Environment variables
| Variable | Description |
|---|---|
AFK_DAEMON_TASK | Default task command (overridden by --task) |
AFK_DAEMON_TASK_ID | Default task ID (overridden by --task-id) |
AFK_TIMEOUT_MS | Per-tick session timeout in milliseconds |
AFK_DAEMON_CWD | Working directory for daemon-spawned sessions |
AFK_DAEMON_HOST | Bind address for the control HTTP surface (default 127.0.0.1, loopback only). Overridden by --host. |
AFK_SESSIONSTART_COOLDOWN_MS | Cooldown between sessionstart fires |
State and logs
The daemon writes state to ~/.afk/state/daemon/ and logs to ~/.afk/logs/. Telemetry records
(task ID, status, duration, cost, response excerpt) are appended to
~/.afk/agent-framework/forge-telemetry.jsonl.
Durable handoffs
When a daemon task raises a blocking question — via the ask_question tool — the daemon has no
interactive surface to answer it. Instead of silently declining and continuing, the daemon persists
the question as a handoff record on disk before sending a Telegram notification. If the Telegram
bot is running, it routes the question to your chat with an inline keyboard (for confirm/choice
questions) or a reply-to message (for text/number questions). Your answer is recorded back to the
durable store, and the daemon task resumes with the response injected.
The write-before-send order is load-bearing: a crash between persisting the record and delivering
the Telegram message is recoverable. On the next daemon start, recoverPendingHandoffs scans
~/.afk/state/daemon/handoffs/ for unanswered records and re-presents them to the operator
without losing the question.
| Handoff lifecycle | Description |
|---|---|
pending | Question written to disk; Telegram message sent |
answered | Operator replied; answer recorded via CAS lock |
expired | Not answered within 24 hours; task lease restored for re-enqueue |
cancelled | Task was cancelled before an answer arrived |
~/.afk/state/daemon/handoffs/<taskId>.jsonNo Telegram, no problem (sort of). If Telegram is not configured, the router still fires a best-effort push when no interactive handler is installed, then declines the elicitation so the task continues rather than hanging indefinitely. The operator won't be able to answer the question, but the run won't stall.
Done verification
The daemon is unattended — there is no operator watching to catch a false completion claim.
On each cron tick the daemon runs a Done verification probe: if the agent's final response
self-certifies Done but no corroborating tool succeeded during that turn
(write_file, edit_file, or bash), the completion is flagged as unverified.
When daemon.verifyDone is enabled (the default), the Telegram notification header is
downgraded from ✅ to ⚠️:
⚠️ Done (unverified) — daemon task: nightly-review (success)
⚠️ Unverified: no file write/edit or successful command recorded this turn — confirm before relying on this.This is a human-tier config key — the agent cannot disable it on its own. To turn it off explicitly (e.g. for a task that legitimately produces no file output):
{
"daemon": {
"verifyDone": false
}
}The verification is heuristic, not a re-run. It checks what tools succeeded in the same tick rather than spawning a secondary session. For tasks where the only honest Done is one that committed a file or ran a command, this catches the most common false-completion pattern (an agent that says "Done" after reading files but writing nothing) without adding any latency to clean runs.
Crash notifications
Uncaught exceptions and unhandled rejections push a crash notification to Telegram (rate-limited
to once per 60 seconds to prevent crash-loop self-DOS). The daemon then exits with code 1.