agentafk
Surfaces

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:

ModeDescription
cronFire on a cron expression (requires --cron)
sessionstartFire once when the daemon starts
bothFire on sessionstart and then on the cron schedule
pullDequeue 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 pull

No --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
FlagDescription
--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
--onceFire 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" --once

The 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
FlagRequiredDescription
--name <label>YesHuman-readable label
--command <cmd>YesCommand to run on each tick
--cron <expr>Yes5-field cron expression
--trigger <mode>Nocron | sessionstart | both (default: cron)
--notify <when>Nofailure | always | never (default: failure)
--disabledNoAdd in disabled state

List tasks

afk schedule list

Prints 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 delete

View execution history

afk schedule logs <id>            # last 10 runs (default)
afk schedule logs <id> -n 25      # last 25 runs

Returns 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

VariableDescription
AFK_DAEMON_TASKDefault task command (overridden by --task)
AFK_DAEMON_TASK_IDDefault task ID (overridden by --task-id)
AFK_TIMEOUT_MSPer-tick session timeout in milliseconds
AFK_DAEMON_CWDWorking directory for daemon-spawned sessions
AFK_DAEMON_HOSTBind address for the control HTTP surface (default 127.0.0.1, loopback only). Overridden by --host.
AFK_SESSIONSTART_COOLDOWN_MSCooldown 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 lifecycleDescription
pendingQuestion written to disk; Telegram message sent
answeredOperator replied; answer recorded via CAS lock
expiredNot answered within 24 hours; task lease restored for re-enqueue
cancelledTask was cancelled before an answer arrived
~/.afk/state/daemon/handoffs/<taskId>.json

No 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):

~/.afk/config/afk.config.json
{
  "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.

On this page