Flip the model: an agent no one watches. Triggered by a clock or an event, it runs to completion alone and hands a machine-readable result to another program. Six concepts — headed vs headless, the anatomy of a run, the contract, automation, guardrails, and composition.
The 6 concepts of headless agents
Tap any concept to jump to its 5-card cluster.
- 1Headed vs HeadlessAssistant you chat with vs worker you deploy
- 2Anatomy of a Headless Runstdin in, JSON on stdout, logs on stderr
- 3The Headless ContractFixed output shape, fixed exit codes
- 4Automation: Cron & CIA clock and a pipeline as drivers
- 5Guardrails Without a HumanTimeout, max-iters, token budget
- 6Piping & CompositionChain agents like Unix tools
An assistant you chat with vs a worker you deploy
An agent runs headless when it has no interactive UI and no human in the loop. The "head" is the front end — the chat window, the keyboard, the eyes watching each step. Remove it and the agent still runs, but now a program drives it (a scheduler, a CI job, a webhook) instead of a person.
Input arrives up front; output is structured for a machine; nobody approves steps mid-run. This is how agents do real work at 3 a.m. — and it changes how you handle input, output, errors, and safety.
A written work order and a locked outbox
BEFORE: Picture a brilliant new analyst who sits beside you for her first month. You describe a task, watch her work, glance at each draft, and say "yes, keep going" turn by turn. That is the interactive agent — a chat window where a human nudges every step.
PAIN: The pain hits the moment work must happen when you are not there. Reports due at 6 a.m., 3,000 tickets to triage, a deploy pipeline with no human waiting. An assistant who only works while you watch cannot do any of it.
MAPPING: Going headless is handing her a written work order (CLI args, a file, an event) with everything up front, because no one answers follow-ups. She works alone and drops a typed JSON report in a locked outbox; a status flag — the exit code — tells the next system "done", "failed", or "needs review" without anyone reading a word.
Headed loop vs headless one-shot
- Headed: human typesA person starts each request and reads the result before continuing.
- Headed: loops back foreverEvery cycle returns to the human to approve the next step — the loop never ends on its own.
- Headless: trigger fires onceCron or an event starts the run; the agent executes the full loop untouched.
- Headless: writes JSON & exitsIt emits structured output for the next system and exits with a status code — no human needed.
Headed or headless?
IF a human must read & approve each step as it happens: → keep it HEADED (chat / REPL) IF the work runs on a schedule, an event, or at high volume with no one watching: → go HEADLESS # most production value lives # headless: it removes the human # throughput ceiling.
One human triages ~40 tickets/hour; the same agent headless clears 3,000 overnight before standup.
Misconceptions
Headless = no UI, no human in the loop — a program drives it and a program consumes it.
The interactive version is a nicer demo; the headless version is the one that actually clears the backlog.
Three I/O surfaces, kept separate
A headless agent is a normal command-line program that happens to call an LLM. It has three I/O surfaces: stdin + args + env carry the work order in; stdout carries the result as structured JSON — and only that; stderr carries human-readable logs and progress.
Getting this split right is 80% of making an agent automation-friendly. The golden rule: machine output to stdout, everything a human would read to stderr.
Two separate chutes, never one
BEFORE: Imagine a worker who shouts a running commentary — "thinking… almost… done!" — into the very same tray where they drop the final typed report.
PAIN: The next machine reaches into the tray expecting one clean report and instead grabs commentary mixed with the answer. It tries to parse "thinking…" as JSON and chokes. This is the single most common headless bug.
MAPPING: Give the worker two chutes: a results chute (stdout) that receives only the typed JSON report, and a notes chute (stderr) for all the muttering. Now the downstream machine reads clean JSON from the results chute, and you can still watch the notes when you want to.
One run, start to finish
- Read the task inPrefer a CLI flag; fall back to stdin only if data was piped — never block waiting for a keystroke.
- Do the workCall the model with a JSON-only system prompt at temperature 0 for repeatability.
- Don't trust the formattingStrip stray code fences, parse the JSON, validate it — the model is easy, distrust is the headless part.
- Deliver & exitPrint JSON to stdout, route every log to stderr, return a status code the scheduler can read.
Pseudocode
FUNCTION read_task(args): IF args.query: RETURN args.query IF stdin was piped: RETURN read(stdin) FAIL "no task; do not block!" FUNCTION main(): task = read_task(args) log("classifying...") # -> stderr raw = model.ask(task) strip_code_fences(raw) verdict = parse_json(raw) # may fail print(verdict) # -> stdout ONLY RETURN exit_code 0
Misconceptions
A headless agent is a CLI program with three surfaces: work order in, JSON on stdout, logs on stderr.
Redirect 2>/dev/null and stdout is pure JSON a tool like jq can slice.
A promise another system can rely on
A human tolerates variety — they can read whatever comes out. A machine cannot. The consumer of a headless agent needs two guarantees: the output always has the same shape, and the exit code always means the same thing. Together these are the headless contract.
The exit code is a one-integer status channel every shell, scheduler, and CI system already understands. Pick a small, documented set — 0 success, 1 transient, 2 bad output, 3 needs review — and stick to it.
The color-coded shipping label
BEFORE: Picture a warehouse where every worker writes their own free-form note on each finished box — "all good", "kinda broke", "check this one maybe".
PAIN: The sorting machine downstream can't act on prose. Does "kinda broke" mean retry or trash? It has to guess, and it guesses wrong — retrying boxes that will never pass, and trashing ones a human should have seen.
MAPPING: The contract is a fixed set of color-coded labels: green (0, ship it), yellow (1, try again later), red (2, never retry — escalate), blue (3, human review). Every worker uses the same four labels, so the sorter acts instantly and correctly with zero guessing.
The exit-code status API
- 0 — successValid result on stdout. The caller consumes the JSON and continues the pipeline.
- 1 — transient failureModel down, timeout. Safe to retry later; alert if it persists.
- 2 — bad outputModel returned non-JSON or failed the schema. Never retry blindly — route to a human / dead-letter.
- 3 — needs reviewRan fine but low confidence. Send to a review queue; do not auto-act.
Pseudocode
FUNCTION run_headless(agent, payload): envelope = {ok, data, error, meta} TRY: envelope.data = agent(payload) envelope.ok = true; code = 0 CATCH NeedsReview: code = 3 CATCH BadOutput: code = 2 CATCH anything: code = 1 envelope.meta.exit_code = code print(envelope) # one true JSON line RETURN code # business logic RAISES the failure # it understands; wrapper just maps it.
Misconceptions
{ok, data, error, meta} — every time, so failure is just ok:false. A shifting shape forces special-casing everywhere.The contract = a fixed JSON envelope + a fixed meaning per exit code. Write the wrapper once and every agent speaks the same dialect.
Small local models miss strict JSON often, so exit code 2 (bad output) is a real, frequent branch here.
A trigger, the agent, a sink
"Headless" only pays off when something other than you launches the agent. Every automation shares one shape: a trigger fires the agent, which produces structured output that flows to a downstream sink — a database, a Slack channel, the next CI step.
The agent in the middle never changes; only the trigger and the sink do. The two drivers that cover most headless work are a clock (cron) and a pipeline (CI).
One machine, many power sources
BEFORE: Imagine a factory machine that only runs when you personally push its start button and stand there feeding it parts by hand.
PAIN: The machine is perfectly good, but its output is capped by your presence. It sits idle overnight, on weekends, and whenever you step away — exactly when the work piles up.
MAPPING: Wire the same machine to different power sources: a timer (cron) that flips it on at 06:00, a doorbell (webhook) that starts it on an event, a conveyor sensor (CI push) that runs it on every change. The machine is identical; only what pulls its trigger differs.
Cron and CI as drivers
- Cron: schedule + commandA crontab line is a five-field time spec plus a command —
0 6 * * *is 06:00 daily. - The wrapper does the workCron itself ignores output and failures; a wrapper script captures both streams and branches on the exit code.
- CI: run on every pushInstall Ollama in the runner, pull the model, then run the agent as a required check.
- Exit code gates the buildA non-zero exit fails the pipeline automatically — the verdict becomes a pass/fail gate.
Pseudocode
# crontab: run every day at 06:00 0 6 * * * run_triage_wrapper FUNCTION run_triage_wrapper(): set PATH explicitly # cron env is minimal OUT = agent < alerts.txt CODE = exit_code_of(agent) CASE CODE: 0 -> append OUT to results.jsonl 2 -> email on-call # never retry 3 -> append to review_queue * -> log "will retry next run" RETURN 0 # wrapper itself succeeds
Misconceptions
PATH explicitly, or the job fails to find your interpreter.The same agent runs from any driver — cron routes exit codes to actions, CI turns them into a build gate.
Write the agent once as a clean stdin→stdout→exit-code program and any driver can run it.
Encode your Ctrl-C reflex as code
The headed version had one thing we just threw away: a human who notices when the agent goes off the rails and hits Ctrl-C. Unattended, nobody is watching — so the guardrails cannot be a person anymore. They have to be code.
Three hard limits replace the human circuit-breaker: a wall-clock timeout that kills the run after N seconds, a max-iterations cap so the loop can never spin forever, and a token budget that aborts before a runaway response gets expensive.
From live lifeguard to automatic drain
BEFORE: When you run an agent interactively and it starts looping or rambling, you just notice and press Ctrl-C. You are a live lifeguard — your attention is the safety net that catches runaway behavior before it costs anything.
PAIN: At 3 a.m. on a cron job, that lifeguard is gone. A model stuck in a tool loop happily burns an hour of CPU; a confused prompt produces a 10,000-token ramble that pins your one GPU and blocks every other scheduled job. "The human will catch it" silently becomes "nothing catches it."
MAPPING: Replace the lifeguard with automatic drains and a timer: a pool that empties itself after N minutes (timeout), a lane counter that stops after a fixed number of laps (max-iters), and a meter that cuts the water before it overflows (token budget). No one has to watch — the pool protects itself.
The three limits
- Wall-clock timeoutThe outermost net. Armed before the loop, disarmed after — catches a single model call that simply hangs.
- Max-iterations capA confused model can decide it's never done. A plain bounded loop means the worst case is N calls, not infinity.
- Token budgetIterations can be few but enormous. Accumulate tokens each turn and abort the moment the running total crosses the cap.
- Add an OS-level backstopPrefix with an outer
timeout --signal=KILLso even a fully wedged process dies — two independent timers.
Pseudocode
FUNCTION run_guarded(task): arm_timeout(max_seconds) # GUARD 1 tokens = 0 TRY: FOR i IN range(max_iters): # GUARD 2 resp = model.step(messages) tokens += resp.total_tokens IF tokens > budget: # GUARD 3 RAISE GuardTripped IF no tool_calls: RETURN result RAISE GuardTripped("max_iters") FINALLY: disarm_timeout() # always
Misconceptions
Timeout + max-iters + token budget replace the human who used to hit Ctrl-C.
Tripping a guard is a normal, deterministic outcome — the agent fails loudly and on purpose instead of hanging silently.
An agent that behaves like a Unix tool
A headless agent that follows the contract is a well-behaved Unix citizen: text in on stdin, JSON out on stdout, status in the exit code. That is exactly the interface grep, jq, and curl use — so your agent slots into a pipeline beside them.
A pipeline chains programs with |: the stdout of each command becomes the stdin of the next. This is the Unix philosophy applied to AI — small programs that do one thing and connect through pipes, with zero glue code.
A relay team vs one exhausted runner
BEFORE: You could build the whole job — triage, filter, draft, post — as one giant script with imports and shared state, like a single runner sprinting the entire race alone.
PAIN: Now testing just the "draft" step means running the whole race. Reusing "triage" elsewhere means copying the runner's legs. One monolith is hard to test, swap, or reuse.
MAPPING: A pipeline is a relay team: each runner (agent or tool) does one leg and hands off a clean baton (JSON) to the next. Each is independently testable, independently swappable, and reusable in races you haven't written yet — because every stage trusts the baton it receives.
Composing with pipes
- Pipe text into the agentThe previous command's stdout becomes the agent's stdin — a file, a webhook payload, another agent.
- Keep logs out of the pipeRedirect
2>/dev/nullso only clean JSON flows downstream to the next stage. - Slice with jqPull one field, or filter to just the high-severity verdicts — no custom parsing code.
- Chain more stagesFeed the result into a second agent, then into
curlto post to Slack. The OS does the wiring.
Pseudocode
# 1) classify + pull one field echo alert | triage 2>/dev/null | jq '.severity' # 2) keep only critical, count them cat alerts | triage 2>/dev/null | jq 'select(.sev=="crit")' | wc -l # 3) chain TWO agents, then post echo alert | triage 2>/dev/null | jq '.summary' | draft_reply 2>/dev/null | curl -d @- SLACK_WEBHOOK
Misconceptions
2>/dev/null) so only JSON flows on.Clean stdin/stdout makes an agent composable — it slots between cat and jq like any Unix tool.
The contract is what makes it work: every stage trusts the previous one emits clean JSON and a meaningful exit code.
Test the core insight
One question per concept. Tap to reveal the answer.
json.loads() fails with "Extra data" but the agent works by hand. Why?ollama pull mistral step before the agent?cat f | agent | jq ...?cat and jq like any other tool. Hard-coded files, prompts, or mixed stdout all break the pipe.Open the full module on desktop
The desktop version has complete, runnable Python and Node code for a headless triage agent against local Mistral, a reusable contract wrapper, guarded loops, cron + GitHub Actions drivers, and a nightly log-triage lab end to end.
Open M21C on Desktop → Contract · cron & CI · guardrails · pipelines · ~55 minM21C · Open Source Track · Production & Deployment