Bonus Production Module
Headless
Agents

You built an agent a person watches. Now flip the model: an agent nobody watches. A clock or an event fires it, it runs to completion alone, and hands a machine-readable result to another program. This is how agents do real work at 3 a.m.

Track 7: Production Deployment ⏱ ~19 min read M21C
Concept 1 of 6

An assistant you chat with vs a worker you deploy

An agent runs headless when it executes with no interactive UI and no human in the loop. The "head" is 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 through args, files, or stdin; output is structured for a machine; nobody approves steps mid-run. It never calls input() or asks "are you sure?" — a non-interactive process that never pauses to ask a question.

Concept 1 of 6

The analyst beside you vs a work order in an outbox

BEFORE: Picture a brilliant new analyst who sits beside you. You describe a task, watch her work, glance at each draft, and say "keep going" or "try the other database." Fast and good — but every task needs you in the chair, reacting turn by turn.

PAIN: The work needs to happen when you are not there. Reports are due at 6 a.m. before anyone is awake. Three thousand tickets need triaging, far more than you can babysit one at a time. An assistant who only works while you watch cannot do any of it. She isn't the problem; the "needs a human in the chair" model is.

MAPPING: Going headless is handing her a written work order (CLI args, a file, an event payload) and a locked outbox. She does the job alone and drops a typed JSON report in the outbox. A status flag — the process exit code — tells the next system "done", "failed", or "needs review" without anyone reading a word.

Concept 1 of 6

Where the human appears — and doesn't

  1. Headed: human typesA person types a request, the agent reasons and acts, the human reads the step and says "keep going."
  2. Headed: loops to the humanEvery cycle bounces back to a person who decides whether to continue — the loop never ends on its own.
  3. Headless: a trigger fires onceA clock or event starts the run; the agent executes the whole loop untouched.
  4. Headless: writes JSON, exitsIt emits structured JSON to stdout, another system consumes it, and it exits with a status code. No human needed.
Triggercron/event
🤖Agentruns alone
📦JSONexit 0
Concept 1 of 6

Headed or headless?

# Which model does this task need?

IF a human must approve each step
   (high stakes, exploratory, one-off):
  USE headed / interactive
  # the human is the driver AND reviewer

ELIF it runs on a schedule or an event,
      unattended, at volume:
  USE headless
  # a program drives; a program consumes

ELSE:
  PREFER headless
  # it composes, scales, and runs at 3am

An interactive agent helps triage one ticket at a time. The same agent on a cron clears all 3,000 overnight tickets before standup.

Concept 1 of 6

Misconceptions

"Headless just means it runs faster with no UI."
Speed is incidental. The defining trait is the absence of a human in the loop: a program triggers it and a program consumes its output. That's what changes how you handle input, output, and errors.
"A headless agent can still prompt if it gets stuck."
There's no one to answer. If it ever blocks on input(), a cron job hangs forever. Everything it needs must be supplied before it starts.

Headless = no interactive UI, no human in the loop. A program drives the agent and a program consumes its structured output.

The interactive version is a nicer demo; the headless version is the one that actually clears the backlog — it removes the human throughput ceiling.

Concept 2 of 6

Three I/O surfaces, kept strictly separate

A headless agent is a normal command-line program that happens to call Claude. It has three I/O surfaces, and getting them right is 80% of making it automation-friendly: the work order in (stdin, args, env), the result out (structured JSON on stdout, and only that), and logs (human-readable, on stderr).

The rule is absolute: machine output to stdout, everything a human would read to stderr. Mix them and the downstream json.loads() chokes on a stray "Thinking..." line.

Concept 2 of 6

The report goes in the outbox, the muttering stays in the room

BEFORE: Your analyst finishes a job and hands over a clean, typed report. Along the way she also mutters progress out loud — "checking the second database now, this one's slow."

PAIN: If she writes that muttering onto the report itself, the system that files it can't parse the page — the notes and the data are tangled on one sheet. One stray sentence and the whole result is unreadable to a machine.

MAPPING: Two separate channels. The typed report (JSON) goes in the outbox (stdout); the running commentary (logs) goes to a notepad on the desk (stderr). Same work, two destinations — so the machine reads a pristine result and a human can still watch the play-by-play.

Concept 2 of 6

Read a task, call Claude, print one JSON line

  1. Read the taskTake it from a --query flag, else from stdin — but only if data was piped in. If neither, fail fast instead of blocking forever.
  2. Call ClaudeOne Messages API call, temperature 0, a system prompt that demands JSON-only output.
  3. Validate the outputStrip a stray code fence, parse the JSON, and record token usage. Never assume the text is valid — verify it.
  4. Emit + exitPrint exactly one JSON line to stdout; route every log to stderr; return a meaningful exit code.
📤stdin/argsthe task
🤖Agentcalls Claude
📦stdoutJSON only
📝stderrlogs
Concept 2 of 6

Pseudocode

# log() ALWAYS writes to stderr, never stdout
FUNCTION read_task(args):
  IF args.query: RETURN args.query
  IF stdin was piped: RETURN read(stdin)
  FAIL "no task"   # don't block forever

task   = read_task(args)
reply  = ASK Claude(task, want="JSON only")
reply  = strip_code_fence(reply)
result = parse_json(reply)   # raises on bad output

PRINT result TO stdout   # the one true line
log("done")              # -> stderr
EXIT 0
Concept 2 of 6

Misconceptions

"Printing logs and the result together is fine — it's all output."
The #1 headless bug. print("Thinking...") before the JSON makes the caller's parser see "extra data." Logs go to stderr (file=sys.stderr / console.error); stdout is JSON only.
"Claude returns JSON, so I can trust the text as-is."
Even reliable models occasionally add a code fence or a stray sentence. A production agent strips, parses, and validates — a parse failure is a real branch to handle.

stdout is for the machine; stderr is for the human. That clean split is what lets a headless agent slot into a pipeline where jq can slice its output.

Read task → call Claude → validate → print one JSON line → exit with a meaningful code.

Concept 3 of 6

A fixed shape and a fixed meaning for every exit code

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.

Concept 3 of 6

A shipping label with standard status codes

BEFORE: A courier drops a package and each driver scribbles their own note — "left it out back", "couldn't find you", "all good." A human at the depot can read each one and figure it out.

PAIN: Automate the depot and the free-text notes are useless. Was "couldn't find you" a retry-tomorrow or a wrong-address-forever? The sorting machine can't tell, so it either retries everything (wasteful) or nothing (missed deliveries).

MAPPING: Replace the scribbles with a fixed set of status codes on the label. "Delivered", "retry", "bad address", "needs signature" — each maps to one automatic action. The exit code is that label: 0/1/2/3, each with a documented meaning the scheduler acts on without reading a word.

Concept 3 of 6

One envelope, four exit codes

  1. Wrap any agent onceA run_headless wrapper runs your agent function and guarantees one fixed {ok, data, error, meta} envelope on stdout — success or failure.
  2. Let the agent name its failureCustom exceptions carry a code: NeedsReview → 3, BadOutput → 2. The logic that understands the task picks the status.
  3. Map outcomes to codes0 = success, 1 = transient (API error, 529 overloaded), 2 = bad output, 3 = needs review. Echo the code into meta too.
  4. Callers branch on one signalThe consumer parses one shape and reads one integer — retry, escalate, or continue — forever.
Concept 3 of 6

Pseudocode

# exceptions carry their own exit code
CLASS BadOutput   -> code 2
CLASS NeedsReview -> code 3

FUNCTION run_headless(agent, payload):
  envelope = { ok:false, data:null, error:null }
  TRY:
    envelope.data = agent(payload)
    envelope.ok   = true; code = 0
  CATCH NeedsReview e: code = 3; envelope.error = e
  CATCH BadOutput   e: code = 2; envelope.error = e
  CATCH any        e: code = 1; envelope.error = e
  envelope.meta = { exit_code: code }
  PRINT envelope TO stdout
  RETURN code   # the process exit code
Concept 3 of 6

Misconceptions

"Collapse every error into exit code 1 for simplicity."
Then a cron wrapper retries everything — including a prompt that reliably produces garbage for one weird input. It retries every 5 minutes forever, burning tokens. Split transient (1, retry) from bad output (2, escalate).
"The wrapper should decide how serious a failure is."
It shouldn't guess. The business logic raises NeedsReview or BadOutput; the wrapper just trusts the code the exception carries.

One fixed envelope + a documented exit-code set. Callers parse one shape and branch on one stable integer — which makes cron and CI drivers completely generic.

Splitting "retry" from "escalate" is the difference between a self-healing job and an infinite, billable loop.

Concept 4 of 6

The same agent, driven by a clock or a pipeline

"Headless" only pays off when something other than you launches the agent. Two drivers cover the vast majority of the work: cron (a time-based scheduler — 0 6 * * * runs it at 06:00 daily) and CI (a pipeline that runs it on every push).

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.

Concept 4 of 6

The same machine on a timer or a conveyor belt

BEFORE: You have one reliable coffee machine. Sometimes you press the button yourself; that's the interactive version, you standing there.

PAIN: But you want coffee ready at 6 a.m. before you're awake, and you also want a fresh cup poured automatically whenever a guest walks in. Pressing the button by hand can't cover either case — you're not there, or you can't watch the door all day.

MAPPING: Wire the same machine to two drivers: a timer (cron) that fires it at 06:00, and a door sensor (a CI push or webhook) that fires it on an event. The machine is unchanged; you just swapped who presses the button. That's headless automation.

Concept 4 of 6

Trigger → agent → branch on exit code

  1. Cron fires a wrapperA crontab line is a schedule plus a command. Cron itself does nothing with output — the discipline lives in the wrapper.
  2. The wrapper branches on the codeCapture stdout and the exit code, then dispatch: 0 → append result, 2 → email on-call, 3 → review queue, else → retry next run.
  3. CI runs it on pushThe same agent runs as a required check. The API key comes from an encrypted secret, injected as an env var — never hard-coded.
  4. Exit code gates the buildA non-zero exit fails the step and turns the build red — a model verdict becomes a pass/fail gate.
Cron / CItrigger
🤖Agentexit code
📊Sinkdb/Slack/CI
Concept 4 of 6

Pseudocode

# cron line: 0 6 * * *  ->  run_triage wrapper
SET PATH explicitly     # cron's env is minimal
LOAD API_KEY from .env  # chmod 600, never commit

OUT, CODE = RUN agent < alerts.txt

SWITCH CODE:
  0: append(OUT -> results.jsonl)   # success
  2: email oncall                   # never retry
  3: append(OUT -> review_queue)    # needs a human
  DEFAULT: log "transient, retry next run"

EXIT 0   # the wrapper itself always succeeds
Concept 4 of 6

Misconceptions

"Cron runs with my normal shell environment."
It runs with a near-empty environment. Set PATH explicitly and load the API key from a chmod-600 .env, or the job fails mysteriously at 3 a.m.
"Put the API key straight in the workflow YAML."
That version-controls your secret. Store it as an encrypted repository secret and inject it via env: KEY: ${{ secrets.KEY }}. Never in YAML, a committed .env, or a CLI arg.

Write the agent once as a clean stdin→stdout→exit-code program; any driver can run it. Cron gives it a clock; CI gives it a pipeline.

That portability is the whole point of the contract — the same triage_agent runs unchanged from both drivers.

Concept 5 of 6

Encode your Ctrl-C reflex as code

Interactively, when an agent starts looping or rambling, you notice and press Ctrl-C — you are a live circuit breaker. At 3 a.m. on a cron job, that net is gone. A stuck agent makes API call after API call, and every call is billed.

Headless safety means three hard limits the program enforces on itself: a wall-clock timeout that kills the run after N seconds, a max-iterations cap so the loop can't spin forever, and a token budget that aborts before a runaway chain gets expensive. These are the replacement for the human who used to be watching.

Concept 5 of 6

A space heater with no one in the room

BEFORE: You run a space heater while you sit beside it. If it starts smelling hot, you reach over and switch it off. Your presence is the safety.

PAIN: Leave the room — or fall asleep — and "I'll just switch it off" evaporates. A stuck heater runs all night, burning power and risking a fire, and nobody is there to notice until the damage is done.

MAPPING: A safe unattended heater has its safety built in: an auto-shutoff timer, a tip-over switch, a thermal fuse. Headless guardrails are the same — a timeout, an iteration cap, and a token budget the program trips on itself, deterministically, with no human in the room.

Concept 5 of 6

Three limits, each for one failure mode

  1. Timeout — the outer netArmed before the loop, disarmed after. Catches the one API call that simply hangs, which the loop body can't predict.
  2. Iteration cap — can't spinBound the reason→act loop with a plain range. A confused model that thinks it's never done still stops at max_iters calls.
  3. Token budget — can't balloonSum input + output tokens each turn; abort the moment the total crosses the cap, before the next billable call.
  4. Fail loud, exit non-zeroAny tripped guard raises, prints JSON, logs to stderr, and exits non-zero — a normal, deterministic outcome, not a hang.
📤Inputpayload
🛡Guardsarmed
In limitsexit 0
Trippedexit 1
Concept 5 of 6

Pseudocode

ARM timeout(max_seconds)      # GUARD 1
tokens = 0
TRY:
  FOR i IN 0..max_iters:      # GUARD 2
    reply   = ASK Claude(messages, tools)
    tokens += reply.usage.total
    IF tokens > max_tokens:      # GUARD 3
      RAISE GuardTripped("over budget")
    IF reply.stop != "tool_use":
      RETURN reply.text        # finished cleanly
    run_tools_and_append(reply)
  RAISE GuardTripped("max_iters hit")
FINALLY:
  DISARM timeout             # never leak the timer
Concept 5 of 6

Misconceptions

"An in-process timeout is enough."
It can fail if the agent is wedged in a blocking syscall that ignores signals. Add an outer OS kill switch too: timeout --signal=KILL 90s .... Two independent timers, inside and outside.
"Guardrails are a nice-to-have; the model usually finishes."
Interactively, yes — you can Ctrl-C. Headless there's no human, so the limits are the only thing that stops a runaway from billing all night. They're essential, not optional.

Timeout + iteration cap + token budget replace the human circuit-breaker. Each maps to one way an unattended run goes wrong.

Tripping a guard is a normal, deterministic outcome — the agent fails loudly and on purpose instead of hanging silently.

Concept 6 of 6

A well-behaved Unix citizen you can chain

An agent that follows the contract — text in on stdin, JSON out on stdout, status in the exit code — uses exactly the interface grep, jq, and curl use. So it slots into a pipeline beside them, where the stdout of each command becomes the stdin of the next via the | operator.

This is the Unix philosophy applied to AI: small programs that each do one thing and connect through pipes. cat alerts.txt | agent | jq 'select(.severity=="high")' reads a file, classifies each line, and keeps the high-severity verdicts — three tools, zero glue code.

Concept 6 of 6

A factory line vs one giant machine

BEFORE: You could build one enormous custom machine that takes raw material at one end and drops a finished product out the other — triage, filter, draft, and post, all welded together inside.

PAIN: When one stage breaks, you can't test it without running the whole monster. Reusing just the "draft" step elsewhere means copying half the machine. Every change risks the entire line.

MAPPING: A pipeline is a factory line of small, standard stations joined by a conveyor belt (the pipe). Each headless agent is one station: independently testable (echo "x" | draft_agent), swappable, and reusable in lines you haven't built yet. The contract is the shared belt they all trust.

Concept 6 of 6

The stdout of each becomes the stdin of the next

  1. Keep logs off the pipeRedirect 2>/dev/null so only JSON flows downstream — the stderr split from concept 2 is what makes this work.
  2. Slice with jqPull one field (jq -r '.severity') or filter (jq 'select(.severity=="critical")') between stages.
  3. Chain two agentsTriage, filter the high-severity summaries with jq, then pipe them into a second draft-response agent.
  4. Fan in and outPipe a webhook event straight into the agent with curl, shape the result with jq, and POST it to Slack.
📄cat filestdin
|
🤖agentJSON out
|
🔬jqfilter
Concept 6 of 6

Pseudocode

# 1) classify one alert, pull one field
echo alert | agent 2>/dev/null | jq -r .severity

# 2) triage a file, keep the critical ones, count
cat alerts | for-each-line( agent ) 2>/dev/null \
  | jq select(.severity == "critical") \
  | count

# 3) chain TWO agents: triage -> draft reply
echo alert | triage_agent 2>/dev/null \
  | jq -r select(high).summary \
  | draft_agent 2>/dev/null | jq -r .draft

# 4) webhook -> agent -> Slack
curl event | agent 2>/dev/null | jq to_slack | curl POST
Concept 6 of 6

Misconceptions

"Build the whole workflow as one big script for visibility."
A monolith can't test the "draft" step without running everything, and reuse means copying code. Four headless tools joined by pipes are each independently testable and swappable.
"For huge overnight jobs, just fire thousands of real-time calls."
Don't. Collect them and submit one Message Batches request — results within 24 hours at half the price, which is fine for a job whose deadline is "before standup."

Clean stdin/stdout means no custom integration code — the OS does the wiring. Your agent sits between cat and jq like any other Unix tool.

Composition beats a monolith: 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.

Open the full module on desktop

The desktop version is the full build — a runnable triage agent, a reusable contract wrapper, cron + GitHub Actions drivers, the three guardrails, and real Unix pipelines — in Python and Node, with a hands-on lab that wires a nightly log-triage job end to end.

Open M21C on Desktop → Full headless build · contract + cron/CI + guardrails + pipelines · ~55 min

M21C · Track 7: Production Deployment