Run afk from scripts and CI
Read prompts from stdin and consume JSON or NDJSON output from one-shot chat.
Use afk chat when a script, CI job, or wrapper needs a single agent request without a REPL. Choose one JSON response for a completed result or newline-delimited JSON for streaming events.
Capture a structured run
Choose an output format
afk chat "Summarize this repository without changing files" --format json > result.jsonFor events as they happen:
afk chat "Summarize this repository without changing files" \
--format stream-json > run.ndjson 2> run.logstream-json writes one OutputEvent JSON object per line to stdout without markdown rendering or ANSI colors. Initialization and diagnostic messages belong on stderr. Keep those streams separate when parsing.
Pass larger prompts through stdin
printf '%s\n' 'Inspect the current diff and report risks without modifying files.' \
| afk chat - --format stream-json > review.ndjsonAn omitted message also reads piped stdin. - explicitly selects stdin; a supplied message argument takes precedence over piped input. Empty or whitespace-only input is rejected.
Check both execution and task outcome
An emitted error event sets exit status 1. A normally completed run exits 0. Missing input and invalid session flags also fail, sometimes before an event stream exists, so read stderr too.
A done event means the model turn ended, not that your business goal was achieved. Inspect the final answer, metadata, and verification evidence before publishing or advancing a pipeline.
JSON response
--format json returns one object rather than the event stream. Its core fields are success, model, message, and timestamp. Best-effort fields include costUsd, durationMs, inputTokens, and outputTokens.
When available, sessionId, witnessLabel, and tracePath identify the exact session and witness record. Do not locate evidence by guessing that the newest file on disk belongs to your run: other sessions may be running concurrently. Missing metadata fields are not zero values or proof of tracing failure.
jq -r '.message' result.json
jq '{sessionId, tracePath, costUsd}' result.jsonThe success field describes the returned response envelope. It is not independent verification of the assistant's claims.
Streaming events
The stream uses AFK-native NDJSON, not server-sent events or OpenAI's streaming wire format. Consumers should branch on type and tolerate new event types and absent optional fields.
Event type | Useful fields and behavior |
|---|---|
chunk | chunk is a MessageChunk, including content, thinking, tool_use, tool_use_detail, and tool_result variants. |
message | A complete message with role, content, optional timestamp, and optional metadata. |
done | Optional metadata includes session ID, stop reason, usage, duration, and cost when provided. |
error | error.message and error.name describe a fatal failure. |
progress | Subagent progress, including task ID, description, token/tool counts, and duration. May be absent. |
suggestion | A model-generated prompt suggestion. |
panel | Skill-emitted display payload in spec, including kind, optional title, and body. |
notice | Display-only text with a semantic kind, such as truncation or refusal. |
stream_retry | The provider is re-streaming a model round. Reset the current round's accumulated text to avoid duplicates. |
rate_limit | A backoff marker, optionally with retryAfterMs. Unlike stream_retry, it does not invalidate text already received. |
tool-activity | activeCount and activeToolUseIds describe currently running tools. |
paused | Usage-limit pause; may include reset time, account, provider, autoResume, and wait deadline. |
resumed | Resume marker with hotSwapped and optional account ID. |
subagent_lifecycle | Child session status such as started, succeeded, failed, or cancelled. |
background_job | Job transition and job ID. |
plan_mode | Plan-mode transition. |
Date values are serialized as ISO 8601 strings. The full TypeScript unions are OutputEvent in src/agent/types/session-types.ts and MessageChunk in src/agent/types/message-types.ts; use the SDK when embedding a typed consumer.
A simplified content exchange looks like this:
{"type":"chunk","chunk":{"type":"content","content":"Hello"}}
{"type":"chunk","chunk":{"type":"content","content":", world!"}}
{"type":"message","message":{"role":"assistant","content":"Hello, world!","timestamp":"2026-10-17T12:00:01.234Z"}}
{"type":"done","metadata":{"durationMs":812,"usage":{"input_tokens":14,"output_tokens":5}}}A usage pause may produce:
{"type":"paused","reason":"usage-limit","resetsAt":"2026-10-17T13:00:00.000Z","autoResume":true}
{"type":"resumed","hotSwapped":false}
{"type":"chunk","chunk":{"type":"content","content":"Continuing after the pause."}}
{"type":"done"}Do not assume every pause will resume. Inspect autoResume, handle a later error, and apply your own job timeout.
Parse with jq
Read captured output to avoid losing the agent's exit status in a pipeline:
# Streamed text fragments
jq -r 'select(.type == "chunk" and .chunk.type == "content") | .chunk.content' run.ndjson
# Final usage, when present
jq 'select(.type == "done") | .metadata.usage' run.ndjson
# Detailed tool invocations
jq 'select(.type == "chunk" and .chunk.type == "tool_use_detail")' run.ndjson
# Complete messages, rather than partial deltas
jq -r 'select(.type == "message") | .message.content' run.ndjsonFor live filtering, add --unbuffered to jq. If piping a live AFK process into tee, enable your shell's pipefail support or explicitly preserve the AFK process's exit code. Without that, $? usually reports tee's status, not AFK's.
Parse in Node.js
This consumer checks the process exit status as well as stream completion:
import { createInterface } from 'node:readline';
import { spawn } from 'node:child_process';
const proc = spawn('afk', ['chat', 'Summarize this repository', '--format', 'stream-json'], {
stdio: ['ignore', 'pipe', 'inherit'],
});
const exited = new Promise((resolve, reject) => {
proc.once('error', reject);
proc.once('close', (code) => resolve(code));
});
let done = false;
let failed = false;
const rl = createInterface({ input: proc.stdout });
for await (const line of rl) {
const event = JSON.parse(line);
if (event.type === 'message') console.log(event.message.content);
if (event.type === 'done') done = true;
if (event.type === 'error') {
failed = true;
console.error(event.error.message);
}
}
const code = await exited;
if (code !== 0 || failed || !done) process.exitCode = 1;If you render deltas instead of complete messages, also implement stream_retry handling so repeated rounds do not duplicate text.
Resume deliberately
--session-id assigns a new session UUID. It is not a resume flag. An existing ID is rejected; use --resume to continue a saved session. --resume, --continue, and --session-id are mutually exclusive.
This POSIX-shell example creates and then resumes a persistent session:
session_id=$(node -p 'require("node:crypto").randomUUID()')
afk chat "Inspect the repository without changing files" \
--session-id "$session_id" --format stream-json > first.ndjson
afk chat "Summarize the main risks you found" \
--resume "$session_id" --format stream-json > second.ndjson--continue selects the most recent persisted session in the current working directory. For automation, explicit IDs avoid ambiguity when multiple runs share a directory. Session persistence, when requested, is subject to successful teardown; retain your output and inspect stderr's resume instructions.
Limits and permissions
--format controls output only. Input remains a plain prompt string, not a streamed conversation protocol. The --stream flag is currently inert; use --format stream-json for machine-readable streaming.
These formats belong to afk chat, not the REPL or daemon. The chat command accepts --max-budget-usd, --task-budget, --max-turns, and --permission-mode; choose explicit limits and permissions for CI rather than relying on interactive prompts that nobody can answer. See permissions and the CLI reference.
--post telegram, --post github, or both can publish the final answer. Those are external effects, not output formatting. Only enable them when the run is authorized to publish; GitHub also accepts --post-pr to select the PR.