Building AI Agents with Claude
Track 7: Production Deployment Bonus Production Module
⏱ 55 min 📊 Advanced

M21C: Headless Agents

In M21 you wrapped your Claude agent as an HTTP service a person or a UI could call. This module flips the model: an agent that no one watches. It is triggered by a clock or an event, runs to completion on its own, and hands a machine-readable result to another program. This is how agents do real work at 3 a.m. — and it changes how you handle input, output, errors, and safety.

🎓 Cert Tip — You Have Already Met Headless Mode

Claude Code itself ships a first-class headless mode: claude -p "fix the failing test" --output-format json runs a full agent non-interactively and prints a structured JSON result — exactly the contract you are about to build by hand. Understanding the moving parts here (stdin/stdout separation, exit codes, guardrails) is what lets you wire claude -p and the Agent SDK into CI and cron with confidence. Both are covered in M25 and M26.

Learning Objectives

  • Define headless execution and contrast it with an interactive REPL or chat loop
  • Choose input channels for an unattended agent: CLI arguments, stdin, files, and environment variables
  • Design a headless contract — structured JSON on stdout, logs on stderr, and meaningful process exit codes
  • Schedule an agent with cron and run it inside a GitHub Actions CI pipeline
  • Replace the missing human with hard guardrails: wall-clock timeout, max iterations, and a token budget cap
  • Compose headless agents into Unix pipelines with tools like jq
  • Know when to reach for the Message Batches API for large overnight jobs

Headed vs Headless: An Assistant You Chat With vs a Worker You Deploy

Before the Pain

BEFORE: Picture a brilliant new analyst on your team. For her first month she sits beside you. You describe a task out loud, watch her work, glance at each draft, and say "yes, keep going" or "no, try the other database." She is fast and good, but every single task needs you in the chair next to her, reacting turn by turn. That is the interactive agent you have built so far — a REPL or a chat window where a human reads each step and nudges the next one.

PAIN: The pain shows up the moment the work needs to happen when you are not there. Reports are due at 6 a.m. before anyone is awake. Three thousand support tickets need triaging, far more than you can babysit one at a time. A deploy pipeline needs the agent to check the release notes automatically, with no human waiting at the keyboard. An assistant who only works while you watch cannot do any of this. She is not the problem; the "needs a human in the chair" access model is.

MAPPING: Going headless is handing that analyst a written work order and a locked outbox. The work order (CLI args, a file, an event payload) contains everything she needs up front, because no one will be there to answer follow-up questions. She does the job alone and drops a typed, structured report (JSON) in the outbox for whatever system picks it up next. A status flag on the outbox — the process exit codeA small integer (0–255) a program returns to the operating system when it finishes. 0 means success; any non-zero value signals a specific kind of failure. Schedulers, shells, and CI systems read this number to decide what to do next. — tells the next system "done", "failed", or "needs review" without anyone reading a word. Same analyst, same skills; the difference is who drives and how the answer is delivered.

Definition: Headless

An agent runs headless when it executes with no interactive UI and no human in the loop. The "head" is the interactive front end — the chat window, the REPLRead–Eval–Print Loop: an interactive prompt that reads one input from a human, evaluates it, prints the result, and waits for the next input. A Python shell or a chat box is a REPL. Headless execution removes this loop entirely., the keyboard, the eyes watching each step. Remove it and the agent still runs — but now it is driven by a program (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.

Definition: Non-Interactive Process

A non-interactive process never pauses to ask a question. It does not call input(), never prompts "are you sure?", and never blocks waiting for a keypress. Everything it needs is supplied before it starts, through stdinStandard input: the default stream a program reads from. In a pipeline, the previous command's output becomes this program's stdin. A headless agent reads a payload here instead of waiting for keystrokes., arguments, files, or environment variables. If a headless agent ever blocks on input(), a cron job will hang forever with no one to type a reply — one of the most common headless bugs.

The Two Models Side by Side

The animation below runs the same agent twice. On the left, the interactive (headed) version: every cycle bounces back to a human who reads and decides whether to continue — the blue loop never ends on its own. On the right, the headless version: a trigger fires once, the agent runs to completion untouched, writes JSON, and exits with a status code. Watch where the human appears — and where they do not.

Headed Loop vs Headless One-Shot
👤 HEADED (interactive)
Human types a request
🧠Agent reasons & calls a tool
👁Human reads the step
💬Human says "keep going"
↻ back to the human — forever
🤖 HEADLESS (one-shot)
Trigger fires (cron / event)
🧠Agent runs the full loop alone
📦Writes structured JSON to stdout
📥Next system consumes it
exit 0 — done, no human needed
Left (headed): human types → agent acts → human reads → human approves → loops back to the human every cycle. Right (headless): a trigger fires once → the agent runs the whole loop alone → writes JSON to stdout → another system consumes it → exits with code 0. The human is in every cycle on the left and absent on the right.
Why It Matters: The Work Only Exists Headless

Consider a support team drowning in tickets. An interactive agent can help an engineer triage one ticket at a time — maybe 40 in a focused hour, gated entirely by how fast that one human can read and approve. The same agent run headless on a cron at 6 a.m. churns through all 3,000 overnight tickets before standup, tagging each with severity and a suggested owner, and drops a JSON summary in a Slack channel. Nobody watched. On Claude Haiku that overnight run costs a few dollars — and if you submit it through the Message Batches APIAnthropic's asynchronous batch endpoint: submit many requests at once, get results within 24 hours, at a 50% discount versus real-time calls. Ideal for large headless jobs where latency does not matter. it is 50% cheaper still. The interactive version is a nicer demo; the headless version is the one that actually clears the backlog, because it removes the human throughput ceiling.

So a headless agent is one a program drives instead of a person. The first practical question is: if no human is typing, how does the work actually get in, and how does the answer get out? That is the anatomy of a headless run.

Anatomy of a Headless Run

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 an agent automation-friendly:

SurfaceCarriesWhy it is separate
stdin + args + envThe work order: the task, parameters, the API keySupplied up front; no mid-run prompts possible
stdoutThe result, as structured JSON — and only thatAnother program parses it; stray text breaks the parse
stderrHuman-readable logs, progress, warningsKeeps logs out of the JSON so both stay clean
The #1 Headless Mistake

Printing logs and the result to the same stream. If your agent does print("Thinking...") and then print(json.dumps(result)) to stdout, the downstream json.loads() chokes on "Thinking...". The rule is absolute: machine output to stdout, everything a human would read to stderr. In Python that means print(..., file=sys.stderr) for logs; in Node, console.error(...) (which writes to stderr) for logs and process.stdout.write(...) for the result.

A Minimal Headless Agent

Here is a complete, runnable headless agent. It reads a task from --query or stdin, makes a single Claude call, and prints one JSON object to stdout. Notice every log line goes to stderr.

WHAT: A self-contained CLI agent that triages a short text and emits one JSON verdict
WHY: Reading from args-or-stdin and writing JSON-only to stdout is the core headless contract
GOTCHA: Logs MUST go to stderr (file=sys.stderr) or they corrupt the JSON a caller parses from stdout
# triage_agent.py  —  run:  python triage_agent.py --query "disk full on web-03"
# WHAT: A headless agent that classifies an ops alert into a JSON verdict
# WHY:  No input() calls, no interactive prompts — safe to run from cron
# GOTCHA: ANTHROPIC_API_KEY must be in the environment; logs go to stderr only

import sys, json, argparse, time
from anthropic import Anthropic

client = Anthropic()                 # reads ANTHROPIC_API_KEY from the environment
MODEL = "claude-sonnet-4-6"

def log(msg: str) -> None:
    # Human-readable progress -> stderr, so stdout stays pure JSON
    print(f"[triage] {msg}", file=sys.stderr)

def read_task(args) -> str:
    # Priority: --query flag, else stdin (so you can pipe text in)
    if args.query:
        return args.query.strip()
    if not sys.stdin.isatty():            # data was piped in
        return sys.stdin.read().strip()
    raise SystemExit("no task: pass --query or pipe text on stdin")

def triage(task: str) -> dict:
    log(f"classifying: {task!r}")
    resp = client.messages.create(
        model=MODEL,
        max_tokens=512,
        temperature=0,
        system=(                          # force JSON-shaped output; we still validate
            "You are an ops triage agent. Reply with ONLY a JSON object: "
            '{"severity": "low|medium|high|critical", '
            '"category": str, "summary": str}. No prose.'
        ),
        messages=[{"role": "user", "content": task}],
    )
    raw = resp.content[0].text.strip()
    # Claude is reliable at JSON, but a stray ``` fence can still appear — strip it
    raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
    verdict = json.loads(raw)                       # raises if not valid JSON
    verdict["tokens"] = {
        "input": resp.usage.input_tokens,
        "output": resp.usage.output_tokens,
    }
    return verdict

def main() -> int:
    ap = argparse.ArgumentParser(description="Headless ops triage agent")
    ap.add_argument("--query", help="alert text; if omitted, read stdin")
    args = ap.parse_args()
    started = time.time()
    try:
        task = read_task(args)
        verdict = triage(task)
        verdict["latency_ms"] = int((time.time() - started) * 1000)
        # THE result -> stdout, as a single JSON line
        print(json.dumps(verdict))
        log(f"done in {verdict['latency_ms']}ms, severity={verdict.get('severity')}")
        return 0
    except json.JSONDecodeError as e:
        log(f"model did not return valid JSON: {e}")
        print(json.dumps({"error": "invalid_model_output"}))
        return 2                                    # distinct code: bad output
    except Exception as e:
        log(f"fatal: {type(e).__name__}: {e}")
        print(json.dumps({"error": str(e)}))
        return 1                                    # generic failure

if __name__ == "__main__":
    sys.exit(main())                                # exit code = the verdict flag
// triage_agent.js  —  run:  node triage_agent.js --query "disk full on web-03"
// WHAT: A headless agent that classifies an ops alert into a JSON verdict
// WHY:  No readline/prompt calls — safe to run unattended from cron or CI
// GOTCHA: console.error writes to stderr (logs); process.stdout is the result
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();        // reads ANTHROPIC_API_KEY from the environment
const MODEL = "claude-sonnet-4-6";

const log = (msg) => console.error(`[triage] ${msg}`);  // -> stderr

async function readTask() {
  // Priority: --query flag, else stdin (so you can pipe text in)
  const flagIdx = process.argv.indexOf("--query");
  if (flagIdx !== -1 && process.argv[flagIdx + 1]) {
    return process.argv[flagIdx + 1].trim();
  }
  if (!process.stdin.isTTY) {               // data was piped in
    const chunks = [];
    for await (const c of process.stdin) chunks.push(c);
    return Buffer.concat(chunks).toString("utf8").trim();
  }
  throw new Error("no task: pass --query or pipe text on stdin");
}

async function triage(task) {
  log(`classifying: ${JSON.stringify(task)}`);
  const resp = await client.messages.create({
    model: MODEL,
    max_tokens: 512,
    temperature: 0,
    system:
      "You are an ops triage agent. Reply with ONLY a JSON object: " +
      '{"severity": "low|medium|high|critical", "category": str, "summary": str}. No prose.',
    messages: [{ role: "user", content: task }],
  });
  let raw = resp.content[0].text.trim();
  raw = raw.replace(/^```json/, "").replace(/^```/, "").replace(/```$/, "").trim();
  const verdict = JSON.parse(raw);          // throws if not valid JSON
  verdict.tokens = {
    input: resp.usage.input_tokens,
    output: resp.usage.output_tokens,
  };
  return verdict;
}

async function main() {
  const started = Date.now();
  try {
    const task = await readTask();
    const verdict = await triage(task);
    verdict.latency_ms = Date.now() - started;
    process.stdout.write(JSON.stringify(verdict) + "\n");   // THE result
    log(`done in ${verdict.latency_ms}ms, severity=${verdict.severity}`);
    return 0;
  } catch (e) {
    const code = e instanceof SyntaxError ? 2 : 1;          // 2 = bad output
    log(`fatal: ${e.name}: ${e.message}`);
    process.stdout.write(JSON.stringify({ error: e.message }) + "\n");
    return code;
  }
}

main().then((code) => process.exit(code));   // exit code = the verdict flag

That is a lot of code at once, so let's read it the way the program runs — in four parts:

  1. The client (lines 8–9). Anthropic() reads ANTHROPIC_API_KEY from the environment — never hard-coded, which matters doubly when this runs unattended in cron or CI. From here the call is the ordinary Messages API you have used all course; nothing about being headless changes the model call itself.
  2. Getting the task in: read_task. This is the headless part. It checks --query first, then falls back to reading stdin only if data was piped in (not sys.stdin.isatty()). That isatty() check is the safety catch: if nobody piped anything and no flag was given, it raises immediately instead of doing what an interactive program would do — block forever waiting for a human to type. A blocked read_task is the classic "my cron job hangs" bug.
  3. Doing the work: triage. A single messages.create with a system prompt that demands JSON-only output, temperature=0 for repeatability, then defense before we trust the result: strip a stray ``` fence, json.loads it (which throws if the model added prose), and record input_tokens/output_tokens from resp.usage. Claude is strong at instruction-following, but a headless agent still never assumes the text is valid JSON — it verifies.
  4. Delivering the answer: main. Notice the shape of the return values. Success prints the JSON to stdout and returns 0. A JSON failure returns 2; any other error returns 1. Those integers are not cosmetic — sys.exit(main()) hands them to the operating system, and the next section shows why a caller cares about the difference between 1 and 2.

Every log(...) call routes to stderr, so the four parts above produce a program whose stdout is only the JSON verdict. The terminal session below shows exactly that split.

Terminal — note stderr logs and the clean JSON on stdout
$ python triage_agent.py --query "disk full on web-03, app returning 500s" [triage] classifying: 'disk full on web-03, app returning 500s' [triage] done in 1120ms, severity=high {"severity": "high", "category": "infrastructure", "summary": "Disk exhaustion on web-03 causing HTTP 500 errors", "tokens": {"input": 92, "output": 38}, "latency_ms": 1120} $ python triage_agent.py --query "..." 2>/dev/null | jq .severity "high"
What Just Happened?

The same program runs three ways with no code change: with --query, with text piped to stdin (echo "..." | python triage_agent.py), and from cron. The logs printed to stderr are visible in the terminal but vanish when you redirect 2>/dev/null — leaving stdout as pure JSON that jq can slice. That clean separation is what lets a headless agent slot into a pipeline. The return 0 / 1 / 2 at the end is not decoration: it is the only thing a scheduler reads to know whether the run succeeded.

The agent runs and prints JSON. But "prints some JSON and some exit code" is not yet a promise another system can rely on. To be a building block, a headless agent needs a stable contract: a fixed output shape and a fixed meaning for each exit code.

The Headless Contract

When a human runs your agent, they tolerate variety — they can read whatever comes out. A machine cannot. The consumer of a headless agent (a cron wrapper, a CI step, the next stage of a pipeline) 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.

Exit Codes Are Your Status API

The exit code is a one-integer status channel that every shell, scheduler, and CI system already understands — you do not have to invent or parse anything. Pick a small, documented set and stick to it:

CodeMeaningWhat the caller should do
0Success — valid result on stdoutConsume the JSON, continue the pipeline
1Transient/operational failure (API error, timeout, 529 overloaded)Retry later with backoff; alert if it persists
2Bad output (model returned non-JSON / failed schema)Do not retry blindly; route to a human / dead-letter
3Needs review — ran fine but low confidenceSend to a human review queue, do not auto-act
Why It Matters: Retry the Right Failures

Collapsing every error into exit code 1 means a cron wrapper retries everything — including a prompt that reliably produces malformed output for one weird ticket. That ticket gets retried every 5 minutes forever, burning tokens and never succeeding. Splitting "transient" (1, safe to retry — a 529 overloaded will clear) from "bad output" (2, never retry, escalate) is the difference between a self-healing job and an infinite, billable loop. The exit code is how you encode which kind of failure happened, in the one channel the scheduler reads automatically.

A Reusable Contract Wrapper

Rather than scatter exit codes through your logic, define the contract once. This wrapper takes any agent function, guarantees a fixed JSON envelope on stdout, and maps outcomes to the codes above.

WHAT: A wrapper that runs any agent fn and emits a fixed {ok, data, error, meta} envelope
WHY: Callers parse one stable shape and branch on one stable exit code — forever
GOTCHA: Custom exceptions carry the exit code, so business logic decides the status, not the wrapper
# contract.py — a stable envelope + exit-code mapping for any headless agent
import sys, json, time

# Custom exceptions let the agent declare *which* failure happened
class BadOutput(Exception):  code = 2     # model output failed schema
class NeedsReview(Exception): code = 3    # ran fine, but low confidence
# anything else -> code 1 (transient/operational), 0 -> success

def run_headless(agent_fn, payload: dict) -> int:
    """Run agent_fn(payload), print a fixed JSON envelope, return an exit code."""
    started = time.time()
    envelope = {"ok": False, "data": None, "error": None, "meta": {}}
    try:
        envelope["data"] = agent_fn(payload)        # your agent logic
        envelope["ok"] = True
        code = 0
    except NeedsReview as e:
        envelope["error"] = {"type": "needs_review", "message": str(e)}
        code = NeedsReview.code
    except BadOutput as e:
        envelope["error"] = {"type": "bad_output", "message": str(e)}
        code = BadOutput.code
    except Exception as e:                            # transient / unexpected
        envelope["error"] = {"type": type(e).__name__, "message": str(e)}
        code = 1
    envelope["meta"] = {
        "exit_code": code,
        "latency_ms": int((time.time() - started) * 1000),
    }
    print(json.dumps(envelope))                       # stdout: the one true line
    return code

# --- usage ---
def my_agent(payload):
    verdict = {"severity": "high"}                    # ... real work here ...
    if verdict["severity"] == "critical":
        raise NeedsReview("critical alerts are never auto-actioned")
    return verdict

if __name__ == "__main__":
    raw = sys.stdin.read() or "{}"
    sys.exit(run_headless(my_agent, json.loads(raw)))
// contract.js — a stable envelope + exit-code mapping for any headless agent
class BadOutput extends Error  { code = 2; }   // model output failed schema
class NeedsReview extends Error { code = 3; }  // ran fine, but low confidence
// anything else -> code 1 (transient); success -> code 0

async function runHeadless(agentFn, payload) {
  const started = Date.now();
  const envelope = { ok: false, data: null, error: null, meta: {} };
  let code;
  try {
    envelope.data = await agentFn(payload);     // your agent logic
    envelope.ok = true;
    code = 0;
  } catch (e) {
    code = e.code ?? 1;                          // exception carries the code
    const type = e instanceof NeedsReview ? "needs_review"
               : e instanceof BadOutput   ? "bad_output"
               : e.name;
    envelope.error = { type, message: e.message };
  }
  envelope.meta = { exit_code: code, latency_ms: Date.now() - started };
  process.stdout.write(JSON.stringify(envelope) + "\n");   // the one true line
  return code;
}

// --- usage ---
async function myAgent(payload) {
  const verdict = { severity: "high" };          // ... real work here ...
  if (verdict.severity === "critical") {
    throw new NeedsReview("critical alerts are never auto-actioned");
  }
  return verdict;
}

const chunks = [];
for await (const c of process.stdin) chunks.push(c);
const payload = JSON.parse(Buffer.concat(chunks).toString() || "{}");
runHeadless(myAgent, payload).then((code) => process.exit(code));

Reading it top to bottom, the wrapper does three jobs and nothing else:

  1. It lets the agent name its own failure. The two custom exceptions, NeedsReview and BadOutput, each carry a code attribute. Your business logic raises the one that fits ("this is critical, a human must see it" vs. "the model gave me garbage"), and the wrapper trusts that choice. The wrapper does not guess the severity of a failure — the code that understands the task does.
  2. It guarantees one fixed envelope. Whatever happens — success, review, bad output, or an unexpected crash — exactly one {ok, data, error, meta} object is printed to stdout. A consumer can therefore write json.loads(line) once and never special-case "but what if it failed?", because failure is just ok: false with a populated error.
  3. It maps each outcome to a stable exit code. The except ladder is ordered most-specific first: NeedsReview → 3, BadOutput → 2, and the bare except Exception at the bottom catches everything else — including an Anthropic APIError — as a transient 1. Success falls through to 0. The meta.exit_code field echoes that same number into the JSON so logs and the process status always agree.

Write this wrapper once and every headless agent you build speaks the same dialect — which is what makes the cron and CI drivers in the next section completely generic.

🎓 Cert Tip — Validate, Don't Assume

Even though Claude follows formatting instructions well, a production headless agent never trusts raw model text. Exit code 2 (bad output) is a real branch you must handle, not a theoretical one — one malformed response should escalate, not crash the pipeline. Pair this wrapper with the structured-output techniques from M04 (tool-based JSON, schema validation, one re-ask) and, for high-stakes fields, the output guardrails from M17. The exam favors "validate then act" over "the model is usually right."

With a contract in place, the agent is now a reliable command-line citizen. Time to give it a driver. Two drivers cover the vast majority of headless work: a clock (cron) and a pipeline (CI).

Automation: Cron & CI

"Headless" only pays off when something other than you launches the agent. The animation shows the shape every automation shares: a trigger on the left fires the agent in the middle, 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.

One Agent, Many Drivers
Cron
0 6 * * *
🔗
Webhook
POST /event
CI step
on: push
🤖
Headless Agent
stdin → stdout
📦
JSON + exit code
{ok, data}
📊
Database
💬
Slack
CI gate
Triggers (cron at 06:00, a webhook, a CI push) all fire the same headless agent, which reads stdin and writes a JSON envelope plus an exit code, which flows to a downstream sink: a database, a Slack message, or a CI pass/fail gate.

Scheduling with Cron

cronA time-based job scheduler on Unix systems. Each line in a "crontab" has a five-field time spec (minute, hour, day-of-month, month, day-of-week) followed by a command to run. "0 6 * * *" means 06:00 every day. is the simplest possible driver. A crontab line is a schedule plus a command. The discipline that makes it production-grade is in the wrapper, not the cron line: capture both streams, branch on the exit code, and never let the job hang.

WHAT: A crontab entry plus a defensive wrapper script that branches on the exit code
WHY: Cron itself does nothing with output or failures — the wrapper turns exit codes into actions
GOTCHA: Cron runs with a near-empty environment; set PATH and load ANTHROPIC_API_KEY explicitly
# /etc/cron.d/triage  — run the triage agent every day at 06:00
# m h dom mon dow   user      command
0 6 * * *   deploy   /opt/agents/run_triage.sh >> /var/log/triage.log 2>&1

# ----- /opt/agents/run_triage.sh -----
#!/usr/bin/env bash
set -euo pipefail
export PATH=/usr/local/bin:/usr/bin:/bin        # cron's PATH is minimal — be explicit
source /opt/agents/.env                         # load ANTHROPIC_API_KEY (chmod 600)

cd /opt/agents
# Feed the day's alerts in on stdin; capture stdout (JSON) and the exit code
OUT="$(cat alerts.txt | python3 triage_agent.py)" && CODE=0 || CODE=$?

case "$CODE" in
  0) echo "$OUT" | jq -c . >> results.jsonl ;;            # success: append result
  2) echo "BAD OUTPUT, escalating" | mail -s triage oncall@co ;;  # never retry
  3) echo "$OUT" >> review_queue.jsonl ;;                 # needs a human
  *) echo "transient failure (code $CODE), will retry next run" ;;  # 1 etc.
esac
exit 0    # the wrapper itself always succeeds; cron should not "fail"
# Cron's five time fields:  minute hour day-of-month month day-of-week
#
#   *  *  *  *  *
#   |  |  |  |  +--- day of week  (0-6, Sun=0)
#   |  |  |  +------ month        (1-12)
#   |  |  +--------- day of month (1-31)
#   |  +------------ hour         (0-23)
#   +--------------- minute       (0-59)
#
# 0 6 * * *      every day at 06:00
# */15 * * * *   every 15 minutes
# 0 */4 * * *    every 4 hours, on the hour
# 0 9 * * 1-5    09:00 Mon-Fri only
#
# Test a crontab spec without waiting:  run the wrapper by hand first.
#   /opt/agents/run_triage.sh ; echo "exit=$?"

Running Inside CI (GitHub Actions)

The second great driver is CI. Here the agent runs on every push — for example, an agent that reviews the changelog or summarizes a diff. The key trick: provide the API key as an encrypted secret, then run the agent and let its exit code gate the pipeline.

WHAT: A GitHub Actions job that runs the headless agent and gates on its exit code
WHY: The agent becomes a required check — a non-zero exit fails the build automatically
GOTCHA: The API key comes from secrets, injected as an env var — never hard-code it in the workflow
# .github/workflows/agent-review.yml
name: Headless agent review
on: [push]

jobs:
  review:
    runs-on: ubuntu-latest
    env:
      # Stored once under Settings -> Secrets -> Actions. Never in the YAML.
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
    steps:
      - uses: actions/checkout@v4

      - name: Install deps
        run: pip install anthropic

      - name: Run headless agent (exit code gates the build)
        run: |
          # The agent reads the changelog and emits a JSON verdict.
          # If it exits non-zero, this step fails and the build goes red.
          git log -1 --pretty=%B | python triage_agent.py | tee verdict.json
          jq -e '.severity != "critical"' verdict.json   # fail build on critical

      - name: Upload verdict artifact
        if: always()                       # keep the JSON even when the gate fails
        uses: actions/upload-artifact@v4
        with:
          name: agent-verdict
          path: verdict.json
What Just Happened?

The exact same triage_agent.py — unchanged — now runs from two completely different drivers. Cron gives it a clock and a wrapper that routes each exit code to a different action (append, email, review queue). CI gives it a pipeline where its exit code, amplified by jq -e, turns a model verdict into a build pass/fail. That portability is the whole point of the contract: write the agent once as a clean stdin→stdout→exit-code program, and any driver can run it.

There is one thing the headed version had that we just threw away: a human who would notice when the agent went off the rails and hit Ctrl-C. Unattended, nobody is watching. So the guardrails cannot be a person anymore — they have to be code.

Guardrails Without a Human

Before the Pain

BEFORE: When you run an agent interactively and it starts looping — calling the same tool over and over, or rambling for thirty seconds — you just notice and press Ctrl-C. You are a live circuit breaker. Your attention is the safety net that catches runaway behavior before it costs anything real.

PAIN: At 3 a.m. on a cron job, that net is gone. An agent stuck in a tool loop will happily make API call after API call and never stop — and every call is billed. A prompt that confuses the model can trigger a long chain of tool turns that runs for ten minutes and racks up real token spend before anything notices. Nobody is there to hit Ctrl-C, so "the human will catch it" silently becomes "nothing catches it, and the invoice grows."

MAPPING: Headless safety means encoding your Ctrl-C reflex as hard limits the program enforces on itself: a wall-clock timeout that kills the run after N seconds, a max-iterations cap so the agent loop can never spin forever, and a token budget that aborts before a runaway chain gets expensive. These are not nice-to-haves in headless mode — they are the replacement for the human who used to be watching.

Wall-clock timeout
kill after N seconds
Max iterations
loop can't spin forever
🎱
Token budget
abort before runaway

The animation traces a guarded headless run. Input enters, passes a guard layer (timeout + budget armed), and runs the agent loop. Two endings are possible: the loop finishes within limits and exits 0, or it trips a guard and exits non-zero — deterministically, with no human involved.

A Guarded Headless Run
📤
Input
stdin payload
🛡
Guards armed
timeout+budget
🧠
Agent loop
reason → act
Within limits
exit 0
Guard tripped
exit 1
A stdin payload passes through an armed guard layer (timeout + token budget) into the agent loop. The run ends one of two ways: it completes within limits and exits 0, or it trips a guard (too slow, too many iterations, or over budget) and exits non-zero — with no human involved.

Encoding the Three Limits

This wraps a Claude tool-use loop in all three guards at once. The timeout is the outermost net; max-iterations bounds the loop; the token budget aborts mid-run if the conversation balloons.

WHAT: A Claude agent loop bounded by a wall-clock timeout, a max-iterations cap, and a cumulative token budget
WHY: With no human to hit Ctrl-C, these three limits are the only thing that stops a runaway from billing
GOTCHA: signal.alarm is Unix-only and main-thread-only; on Windows use a watchdog thread or the timeout command
# guarded_loop.py — three hard limits replace the human circuit-breaker
import signal, sys, json
from anthropic import Anthropic

client = Anthropic()
TOOLS = [...]   # your tool schemas (see M05 / M12)

class GuardTripped(Exception): pass

def _timeout_handler(signum, frame):
    raise GuardTripped("wall-clock timeout exceeded")

def run_guarded(task: str, *, max_seconds=60, max_iters=8, max_tokens=8000) -> dict:
    # GUARD 1: wall-clock timeout (Unix). SIGALRM fires after max_seconds.
    signal.signal(signal.SIGALRM, _timeout_handler)
    signal.alarm(max_seconds)

    messages = [{"role": "user", "content": task}]
    tokens_used = 0
    try:
        for i in range(max_iters):                  # GUARD 2: hard iteration cap
            resp = client.messages.create(
                model="claude-sonnet-4-6", max_tokens=1024,
                tools=TOOLS, messages=messages,
            )
            tokens_used += resp.usage.input_tokens + resp.usage.output_tokens
            print(f"[guard] iter={i} tokens={tokens_used}", file=sys.stderr)

            if tokens_used > max_tokens:             # GUARD 3: token budget
                raise GuardTripped(f"token budget {max_tokens} exceeded")

            if resp.stop_reason != "tool_use":       # loop ends: final answer
                text = "".join(b.text for b in resp.content if b.type == "text")
                return {"result": text, "iters": i + 1, "tokens": tokens_used}

            # Append the assistant turn, run each tool_use block, append tool_result
            messages.append({"role": "assistant", "content": resp.content})
            # ... execute tools, append {"role":"user","content":[tool_result, ...]} ...
        raise GuardTripped(f"max_iters {max_iters} reached without finishing")
    finally:
        signal.alarm(0)                              # always disarm the timer

if __name__ == "__main__":
    try:
        out = run_guarded(sys.stdin.read().strip())
        print(json.dumps({"ok": True, **out}))
        sys.exit(0)
    except GuardTripped as e:
        print(json.dumps({"ok": False, "error": str(e)}))
        print(f"[guard] TRIPPED: {e}", file=sys.stderr)
        sys.exit(1)                                  # deterministic non-zero exit
// guarded_loop.js — three hard limits replace the human circuit-breaker
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const TOOLS = [/* your tool schemas (see M05 / M12) */];

class GuardTripped extends Error {}

async function runGuarded(task, { maxSeconds = 60, maxIters = 8, maxTokens = 8000 } = {}) {
  // GUARD 1: wall-clock timeout via an AbortController + a race
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), maxSeconds * 1000);

  const messages = [{ role: "user", content: task }];
  let tokensUsed = 0;
  try {
    for (let i = 0; i < maxIters; i++) {            // GUARD 2: hard iteration cap
      const resp = await client.messages.create(
        { model: "claude-sonnet-4-6", max_tokens: 1024, tools: TOOLS, messages },
        { signal: ac.signal }                       // abort kills the in-flight call
      );
      tokensUsed += resp.usage.input_tokens + resp.usage.output_tokens;
      console.error(`[guard] iter=${i} tokens=${tokensUsed}`);

      if (tokensUsed > maxTokens)                   // GUARD 3: token budget
        throw new GuardTripped(`token budget ${maxTokens} exceeded`);

      if (resp.stop_reason !== "tool_use") {        // loop ends: final answer
        const text = resp.content.filter((b) => b.type === "text").map((b) => b.text).join("");
        return { result: text, iters: i + 1, tokens: tokensUsed };
      }
      // Append the assistant turn, run each tool_use block, append tool_result
      messages.push({ role: "assistant", content: resp.content });
      // ... execute tools, push { role: "user", content: [toolResult, ...] } ...
    }
    throw new GuardTripped(`maxIters ${maxIters} reached without finishing`);
  } finally {
    clearTimeout(timer);                            // always disarm the timer
  }
}

const chunks = [];
for await (const c of process.stdin) chunks.push(c);
try {
  const out = await runGuarded(Buffer.concat(chunks).toString().trim());
  process.stdout.write(JSON.stringify({ ok: true, ...out }) + "\n");
  process.exit(0);
} catch (e) {
  process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
  console.error(`[guard] TRIPPED: ${e.message}`);
  process.exit(1);                                  // deterministic non-zero exit
}

Each guard maps to one specific way an unattended run goes wrong, so it helps to see what trips which:

  1. Guard 1 — the timeout (signal.alarm / AbortController). This is the outermost net, armed before the loop and disarmed in finally so it never leaks into the next run. It catches the failure mode you cannot predict from the loop body: one API call that simply hangs. After max_seconds, SIGALRM fires (or the request aborts) and raises GuardTripped mid-call.
  2. Guard 2 — the iteration cap (for i in range(max_iters)). A tool-use agent loops "reason → call a tool → observe" until stop_reason is no longer "tool_use". A confused model can decide it is never done. Bounding the loop with a plain for range means the worst case is max_iters calls, not infinity — the loop literally cannot spin forever.
  3. Guard 3 — the token budget (tokens_used > max_tokens). Iterations can be few but enormous — a tool result that balloons the conversation, or a model that starts emitting a wall of text. We accumulate input_tokens + output_tokens every turn and abort the moment the running total crosses the cap, before the next billable call.

All three funnel into the same place: a GuardTripped exception, a JSON line on stdout describing what happened, a log on stderr, and sys.exit(1). Tripping a guard is a normal, expected, deterministic outcome — the agent fails loudly and on purpose instead of hanging silently. That is exactly the behavior the missing human used to provide.

Belt and Suspenders: the OS Timeout

In-process timeouts can fail if the agent is wedged in C code or a blocking syscall that ignores signals. Always add an outer OS-level kill switch that does not depend on your process behaving. On Linux, prefix the command with timeout: timeout --signal=KILL 90s python guarded_loop.py. Now even a fully hung agent dies after 90 seconds. Two independent timers — one inside the program, one outside it — is the headless equivalent of a human and a smoke alarm.

A guarded agent that speaks the headless contract is not just safe to automate — it is also composable. Because it reads stdin and writes JSON to stdout, it behaves like any other Unix tool, which means you can chain it.

Piping & Composition

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. This is the Unix philosophy applied to AI: small programs that do one thing and connect through pipes.

Definition: Pipeline

A pipeline chains programs with the | operator: the stdout of each command becomes the stdin of the next. cat alerts.txt | python triage_agent.py | jq 'select(.severity=="high")' reads a file, classifies each line with the agent, and keeps only the high-severity verdicts — three independent tools, zero glue code. Because data flows as a stream, a pipeline starts producing output before the input is fully consumed.

WHAT: Real pipelines composing the headless agent with standard Unix tools
WHY: Clean stdin/stdout means no custom integration code — the OS does the wiring
GOTCHA: Keep stderr logs out of the pipe (or redirect with 2>/dev/null) so only JSON flows downstream
# 1) Classify one alert and pull a single field with jq
echo "db replica lag 45s on orders-db" | python triage_agent.py 2>/dev/null \
  | jq -r '.severity'
# -> high

# 2) Triage a whole file, keep only the critical ones, count them
cat overnight_alerts.txt \
  | while read -r line; do echo "$line" | python triage_agent.py 2>/dev/null; done \
  | jq -c 'select(.severity=="critical")' \
  | tee critical.jsonl | wc -l
# -> 3

# 3) Chain TWO agents: triage, then for high-sev ones, draft a response
echo "$ALERT" | python triage_agent.py 2>/dev/null \
  | jq -r 'select(.severity=="high") | .summary' \
  | python draft_response_agent.py 2>/dev/null \
  | jq -r '.draft'

# 4) Fan an event from a webhook straight into the agent and post to Slack
curl -s "$EVENT_URL" \
  | python triage_agent.py 2>/dev/null \
  | jq '{text: ("Triage: " + .severity + " — " + .summary)}' \
  | curl -s -X POST -H 'Content-type: application/json' -d @- "$SLACK_WEBHOOK"
Why It Matters: Composition Beats a Monolith

You could build all of pipeline #3 — triage, filter, draft, post — as one big Python script with imports and shared state. But then testing the "draft" step means running the whole thing, and reusing triage elsewhere means copying code. As four headless tools joined by pipes, each is independently testable (echo "x" | python draft_response_agent.py), independently swappable, and reusable in pipelines you have not written yet. The contract is what makes this possible: every stage trusts that the previous one emits clean JSON and a meaningful exit code.

You now have every piece: a contract, drivers, guardrails, and composition. The lab puts them together into one real, runnable headless job.

Lab: A Nightly Log-Triage Agent

Build a headless agent that a cron job runs every night: it reads a log file, asks Claude to flag anomalies, emits a JSON report, exits with a meaningful code, and the wrapper routes the result.

Build the agent (reads stdin, writes JSON)

Start from triage_agent.py above. Change the system prompt to: "You are a log-analysis agent. Given raw log lines, return JSON: {"anomalies": [{"line": str, "reason": str, "severity": str}], "clean": bool}." Keep all logging on stderr.

Wrap it in the contract

Use run_headless from the contract section. Raise NeedsReview when any anomaly is severity == "critical" (exit 3), and BadOutput if the model's text fails to parse as your schema (exit 2).

Arm the guardrails

Wrap the Claude call in the timeout + max-iters + token-budget guards. Set max_seconds=30 so a wedged run cannot hold up the rest of the nightly schedule. Add an outer timeout --signal=KILL 45s in the wrapper as a backstop.

Drive it from cron

Write run_triage.sh with the case "$CODE" dispatch: exit 0 appends to reports.jsonl, exit 3 appends to review_queue.jsonl, exit 2 emails on-call, anything else logs "will retry". Add the crontab line for 02:00 daily, and source a chmod-600 .env for the API key.

Verify the full path

Run by hand first: cat sample.log | ./run_triage.sh; echo "exit=$?". Confirm clean JSON lands in the right file for each exit code, that stderr logs are readable, and that a deliberately huge log trips the timeout and exits non-zero without hanging.

Cost Tip: Batch the Big Jobs

If your nightly job triages thousands of log files, do not fire thousands of real-time calls. Collect them and submit one Message Batches request: results land within 24 hours at half the price — and "within 24 hours" is exactly fine for a job whose deadline is "before standup." Reserve real-time calls for the interactive path; send the unattended bulk work to the batch endpoint.

Knowledge Check

Test your understanding of headless execution, the headless contract, automation, and guardrails.

1. What is the defining characteristic of a headless agent?

AIt runs faster because it skips rendering a UI
BIt uses a smaller, cheaper model to save money
CIt runs with no interactive UI and no human in the loop — driven by a program and consumed by a program
DIt can only be triggered by cron, never by an event
Correct! Headless means no interactive UI and no human in the loop: a program (cron, CI, a webhook) triggers it, and a program consumes its output. Speed, model size, and trigger type are incidental, not definitional.
Not quite. The defining trait is the absence of an interactive UI and a human in the loop — a program drives it and a program consumes its output. Speed, model size, and trigger type are incidental.

2. A cron job runs your agent and a downstream json.loads() intermittently fails with "Extra data" errors. The agent works fine when you run it by hand. What is the most likely cause?

ACron is corrupting the JSON encoding
BLog/progress lines are being printed to stdout alongside the JSON, so the parser sees extra text
CClaude cannot produce JSON reliably
DCron cannot capture stdout at all
Correct! This is the #1 headless bug: log/progress text is going to stdout alongside the JSON result, so the parser sees "extra data." Send every human-readable line to stderr (file=sys.stderr / console.error) and keep stdout for JSON only.
Not quite. The classic cause is log/progress text leaking onto stdout next to the JSON. The fix is to route all human-readable output to stderr and keep stdout for the JSON result only.

3. Your agent reliably returns non-JSON garbage for one malformed input. Which exit code should it use so the cron wrapper does not retry it forever, and why?

ACode 1, because all failures should be treated identically
BA distinct code like 2 (bad output), so the wrapper escalates instead of retrying a failure that will never succeed
CCode 0, because the agent technically ran
DIt should print an error and never set an exit code
Correct! A deterministic failure that will never succeed should get a distinct code like 2 so the wrapper escalates to a human or dead-letter queue instead of retrying forever. Code 1 causes an infinite retry loop; code 0 would falsely signal success.
Not quite. Use a distinct code (e.g. 2) for deterministic bad output so the wrapper escalates instead of retrying. Collapsing it into 1 retries forever; 0 falsely signals success.

4. Why are a wall-clock timeout, a max-iterations cap, and a token budget considered essential for headless agents but merely nice-to-have interactively?

AThey make the agent produce better answers
BInteractively a human can hit Ctrl-C on a runaway; headless there is no human, so the limits must be enforced by code
CThey are required by the cron specification
DThey reduce the SDK download size
Correct! Interactively, your attention is the circuit breaker — you notice a runaway and press Ctrl-C. Headless, that human is gone, so the timeout, iteration cap, and token budget are the only things that can stop a loop from billing unattended.
Not quite. The reason is the missing human: interactively you can Ctrl-C a runaway, but a headless run has no one watching, so the limits must be enforced in code.

5. In the GitHub Actions workflow, the agent needs ANTHROPIC_API_KEY. What is the right way to provide it?

AHard-code the key directly in the workflow YAML so it is version-controlled
BCommit a .env file with the key to the repository
CStore it as an encrypted repository secret and inject it via env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
DPass it as a command-line argument in the run step
Correct! Store the key as an encrypted Actions secret and inject it as an environment variable. Hard-coding it in YAML, committing a .env, or passing it as a CLI arg all leak the secret into version control or process listings.
Not quite. The key must live as an encrypted repository secret and be injected via an env var. Hard-coding it, committing a .env, or passing it as a CLI argument all expose the secret.

6. Which design makes a headless agent composable in a Unix pipeline like cat f | agent | jq ...?

AWriting results to a hard-coded log file
BPrompting the user for confirmation before printing
CPrinting logs and results both to stdout for visibility
DReading the task from stdin and writing only structured JSON to stdout, with logs on stderr
Correct! Composability comes from the Unix contract: read the task from stdin, write only structured JSON to stdout, and put logs on stderr. That lets the agent sit between cat and jq like any other tool.
Not quite. The composable design reads from stdin and writes only JSON to stdout, with logs on stderr. Hard-coded files, interactive prompts, or mixed stdout all break the pipe.
0/6

Summary

You learned to turn a Claude agent into an unattended worker:

  • Headless vs headed: a program drives the agent and a program consumes its output — no UI, no human approving steps mid-run.
  • The three I/O surfaces: the work order arrives via stdin/args/env, the JSON result goes to stdout and only stdout, and every log goes to stderr.
  • The contract: a fixed {ok, data, error, meta} envelope plus a documented exit-code set (0/1/2/3) so callers branch on one stable signal.
  • Drivers & guardrails: cron and CI launch the same program; a timeout, iteration cap, and token budget replace the human who used to hit Ctrl-C — and the Message Batches API handles the bulk overnight work at half the cost.

Next, M23: Capstone Project Series puts deployment, cost control, and headless operation together into a full production agent.