Skip to content

Headless runs

kin -p runs one agent turn with no UI at all — the final assistant text goes to stdout, everything else goes to stderr, and the exit code tells a script exactly how the run ended. It's the shape for cron jobs, CI steps, and shell pipelines — and the engine Outpost's scheduled jobs fire on a timer.

kin -p "Read README.md and summarize it in one sentence."
git diff | kin -p "Write a commit message for this diff."
kin -p "Audit the TODOs in src/ and write a report" --output ~/.kin/cron/output/todo-audit/$(date +%s).md

Exit codes — the contract

Code Meaning What a script should do
0 Clean run — the model finished, nothing needed a human. Consume stdout.
1 Error — backend failure, turn cap, token budget, loop guard, or a crashed run. Check stderr / the report; retry or alert.
2 Needs-human — the run finished (or was cut short) but at least one action was blocked pending human approval or input. Alert a human; resume the session in the TUI (kin --resume <id>, printed on stderr) to answer.

No mainstream agent CLI distinguishes "blocked pending a human" at the process level — Claude Code aborts a headless run with an undocumented status, and Gemini CLI folds it into a generic tool error. kin reserves 2 for it (promoting Claude Code's hook-layer convention, where exit 2 means "blocking — needs attention", to the process level) so automation can tell "failed" from "waiting on you". Argparse usage errors also exit 2 (the argparse default); those happen before any run starts and print a usage message instead of a report.

Terminal bell on completion

If you're running kin -p interactively from another shell/tab and watching stderr, a run finishing writes a terminal bell/OSC 9 notification there (a plain bell everywhere, plus a native desktop notification on a capable terminal — Ghostty, iTerm2, WezTerm, kitty). Only fires when stderr is a real terminal (sys.stderr.isatty()) — a redirected/piped run (the normal cron shape) gets no escape bytes in its log. KIN_NOTIFY=0 disables it.

The permission envelope — fail-closed, never hanging

Headless runs use the same modes and the same permission gate as the TUI — auto by default (edits + sandboxed shell run without prompts), --mode strict to gate everything. The one thing that changes is what happens when a call would prompt:

  • In the TUI, an ASK-gated tool call (or the ask tool, or present_plan) parks the turn on a modal until you answer.
  • Headless there is no one to answer, so the call never runs. The model instead receives a structured tool error — error: needs-human: thewrite_filecall (…) requires human approval, and this is a headless run with no one to ask… — so it can adapt, work with the tools that are allowed, and finish the turn by reporting what's left for you. The run then exits 2.

Nothing is ever silently auto-approved, and nothing hangs: the envelope replaces only the human resolution of an ASK — it never widens an ALLOW, and the planning freeze, the protected-write downgrade, and the shell allowlist all apply exactly as in the TUI. See Modes & permissions and Auto mode & the sandbox.

Strict mode headless = report-only

kin -p --mode strict "…" can read and research but every edit and shell command becomes a needs-human error. Useful when you want a headless run that proposes work without doing any of it.

MCP servers are not connected in headless runs (their first-run approval is a human gate), and --agent is refused with -p (the agent profile's approval/ask modals need a TTY). --resume / --continue ARE supported headless as of WP2 — see Resuming headless below.

Resuming headless by continuation

A needs-human run isn't dead-end headless — you can resume it with a second kin -p whose prompt is the framed human answer:

# 1. A run that halts on an `ask` (exit 2, sidecar `needs_human`):
kin -p "standardize one env-var name; ask me which" --output /tmp/r.md
# → exit 2; /tmp/r.md.json carries session_id + needs_human[]

# 2. Resume that session with the framed answer as the new prompt:
kin -p --resume <session-id> --output /tmp/r2.md \
  -- "[Operator answer to your pending question(s)]
Q1 (question): KIN_FOO
Continue the task; do not re-ask."
# → exit 0; the model uses the answer and finishes

This is resume by continuation: the saved journal is full-fidelity and incremental, so it already contains the model's ask tool call and the structured needs-human tool error the headless envelope returned. On resume the model sees its own question, the "needs-human" error, its own "I'll wait" finish, then the NEW user turn carrying the framed answer — a natural continuation. No history is rewritten (the alternative — rewriting the ask tool_result in a journal copy — was a planner-reserved fallback that the live Qwen pass^5 proved unnecessary).

Mechanics:

  • The resumed run keeps the same session_id (continuation, not a fork) — the sidecar's session_id field is the continuation identity, so a scheduler can stamp the resumed run row against the original.
  • --continue resolves to the newest session for the workdir (or exits 1 if there isn't one); --resume <id> takes an explicit id. A bad id exits 1 with a clean [kin] could not resume session …: … message (NOT a traceback).
  • The resumed run can halt again (exit 2) if the model re-asks or hits another gate — the needs-human envelope is preserved across resume.
  • The Outpost scheduler uses this to answer a halted job via the dashboard Inbox (see Scheduled jobs) — the framed answer is built server-side from the inbox item's structured questions.

--no-save is ignored on a resume (a resume that doesn't persist is nonsensical — the journal is the continuation substrate).

Prompt input

  • Positional: kin -p "do the thing".
  • Piped stdin: echo "context" | kin -p (10 MB cap). kin -p - forces stdin.
  • Both: piped stdin is prepended as context, the positional is the instruction — git diff | kin -p "review this diff".

Output

  • stdout — the final assistant text, nothing else.
  • stderr[kin]-prefixed diagnostics: errors, the needs-human list, the resume hint, the report path.
  • --output <file> — a markdown run report: status, session id, model, mode, workdir, duration, the prompt, needs-human items (with the resume command), errors, and the final text. Parent directories are created, so a per-job convention like ~/.kin/cron/output/<job>/<timestamp>.md works out of the box.
  • <file>.json — a machine-readable sidecar written next to every --output report: {version, exit_code, status, done_reason, session_id, model, mode, duration_s, usage: {input, output, total}, needs_human, errors}. usage is the run's cumulative token spend (best-effort — an endpoint that omits usage counts 0, same as the token budget); needs_human items carry the structured question payload (question/header/options/multi_select) so a scheduler or webhook consumer doesn't parse markdown. The Outpost scheduler reads it to stamp token counts onto run rows and to build the machine door's job.halted webhook payload. Best-effort at both ends: a missing/corrupt sidecar degrades to report-only, never fails the run.

Fleet safety — the per-run token budget

An unattended run on a shared, self-hosted GPU needs a leash: nothing else stops a runaway loop or an over-eager fan-out from burning tokens (and starving your interactive sessions). The turn cap is unlimited by default — the doom-loop guard catches identical-repeat stuck loops, but a progressive runaway (different calls each round) would otherwise run until the context window fills. The per-run token budget is the primary leash:

kin -p "Audit the TODOs in src/ and write a report" --token-budget 150000
  • What counts: prompt + completion tokens, summed across every model round of the run — subagent rounds included (a fan-out draws down the same budget, so it can't multiply the leash). Total (not completion-only) is deliberate: on a self-hosted GPU the whole prompt is re-prefilled every round, so a 40-round loop over a big history is mostly prompt compute — a completion-only count would score a runaway near zero.
  • When it stops: cleanly, at the next model-round boundary — never mid-tool-execution. The round that crosses the line finishes its tool calls (history stays valid, the session resumes normally in the TUI); the next model call never fires.
  • How it ends: done reason token_budgetexit 1, with [kin] the run was cut short: token_budget on stderr and the reason in the --output report. In the TUI the turn ends with a visible system note instead.
  • Where it's set: --token-budget N (headless flag) > KIN_TOKEN_BUDGET env > the token_budget settings key. 0 / unset = off. A scheduler passes a per-job budget via env — the subprocess picks it up like any other KIN_* var.
  • Not model-writable, on purpose: the budget exists to stop a runaway model, so the model can never raise its own.

A round cap is available as a secondary lever for operators who want a hard ceiling independent of tokens: --max-turns N / KIN_MAX_TURNS / the max_turns settings key caps the model↔tool round-trips in one turn (done reason turn_cap). 0 = unlimited (the default). For hours-long large-model runs you'll usually leave it off and lean on the token budget + the doom-loop guard.

The budget is best-effort by construction on endpoints that omit usage from the stream (vLLM and both first-party wires report it). For queue-priority on a shared vLLM (--scheduling-policy priority), see the priority passthrough.

Sessions journal like any other run

A headless run writes the normal session journal (unless --no-save), so you can pick it up interactively afterwards — kin --resume <session-id> replays it in the TUI, where the blocked approvals can be re-driven by just asking again. All the usual configuration applies: --provider, --model, --base-url, --preset, sampling flags, KIN_* env vars, and both settings.toml layers resolve exactly as for a TUI launch. See the CLI reference for the full flag list, including the -p-only flags (--output, --token-budget, --max-turns).

Memory reflection on a clean stop

A run that ends cleanly (done_reason: stop) fires one bounded memory reflection pass before the session closes — a short extra turn over the live memory tool with the full transcript in context, so a kin -p run or an Outpost scheduled job writes memory just like an interactive TUI session. It's best-effort and can never change the run's outcome: the pass is bounded to ~90 s, snapshots done_reason / final_text / needs_human before it runs, and a run that succeeded is never recorded as error because of it. Runs that end any other way (error, token_budget, turn_cap, loop_detected) skip it — reflecting on a failed run is noise. Disable it with memory_reflect = false / KIN_MEMORY_REFLECT=0.

Cron example

# Nightly repo digest at 06:30; alert on anything non-zero.
30 6 * * * cd ~/proj && kin -p "Summarize yesterday's git activity" \
  --output ~/.kin/cron/output/digest/$(date +\%Y-\%m-\%d).md \
  || notify-send "kin digest needs attention ($?)"