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
asktool, orpresent_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 exits2.
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'ssession_idfield is the continuation identity, so a scheduler can stamp the resumed run row against the original. --continueresolves 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>.mdworks out of the box.<file>.json— a machine-readable sidecar written next to every--outputreport:{version, exit_code, status, done_reason, session_id, model, mode, duration_s, usage: {input, output, total}, needs_human, errors}.usageis the run's cumulative token spend (best-effort — an endpoint that omits usage counts 0, same as the token budget);needs_humanitems 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'sjob.haltedwebhook 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:
- 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_budget→ exit1, with[kin] the run was cut short: token_budgeton stderr and the reason in the--outputreport. In the TUI the turn ends with a visible system note instead. - Where it's set:
--token-budget N(headless flag) >KIN_TOKEN_BUDGETenv > thetoken_budgetsettings key.0/ unset = off. A scheduler passes a per-job budget via env — the subprocess picks it up like any otherKIN_*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.