openai client — zero API cost and zero rate limits, which is exactly what you want when an agent runs unattended on a schedule. The same code works against Groq or any OpenAI-compatible endpoint by changing one base_url.
M21C: Headless Agents
In M21 you wrapped your 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.
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 - Build and run a nightly log-triage agent end to end against local Mistral
Headed vs Headless: An Assistant You Chat With vs a Worker You Deploy
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.
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.
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.
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 in ~25 minutes on a single local Mistral instance, tagging each with severity and a suggested owner, and drops a JSON summary in a Slack channel before standup. Nobody watched. The interactive version is a nicer demo; the headless version is the one that actually clears the backlog. Most production agent value lives on the headless side precisely because it removes the human throughput ceiling.
Anatomy of a Headless Run
A headless agent is a normal command-line program that happens to call an LLM. It has three I/O surfaces, and getting them right is 80% of making an agent automation-friendly:
| Surface | Carries | Why it is separate |
|---|---|---|
stdin + args + env | The work order: the task, parameters, secrets | Supplied up front; no mid-run prompts possible |
stdout | The result, as structured JSON — and only that | Another program parses it; stray text breaks the parse |
stderr | Human-readable logs, progress, warnings | Keeps logs out of the JSON so both stay clean |
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, runs a small tool-using loop against local Mistral, and prints one JSON object to stdout. Notice every log line goes to stderr.
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: We point the OpenAI client at Ollama; logs go to stderr only
import sys, json, argparse, time
from openai import OpenAI
# Ollama exposes an OpenAI-compatible API on localhost:11434
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "mistral"
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.chat.completions.create(
model=MODEL,
# Force JSON-shaped output; we still validate it below
messages=[
{"role": "system", "content": (
"You are an ops triage agent. Reply with ONLY a JSON object: "
'{"severity": "low|medium|high|critical", '
'"category": str, "summary": str}. No prose.'
)},
{"role": "user", "content": task},
],
temperature=0,
)
raw = resp.choices[0].message.content
usage = resp.usage
# Mistral sometimes wraps JSON in ```; strip a fenced block defensively
raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
verdict = json.loads(raw) # raises if not valid JSON
verdict["tokens"] = {
"prompt": usage.prompt_tokens,
"completion": usage.completion_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 OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama" });
const MODEL = "mistral";
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.chat.completions.create({
model: MODEL,
temperature: 0,
messages: [
{ role: "system", content:
"You are an ops triage agent. Reply with ONLY a JSON object: " +
'{"severity": "low|medium|high|critical", "category": str, "summary": str}. No prose.' },
{ role: "user", content: task },
],
});
let raw = resp.choices[0].message.content.trim();
raw = raw.replace(/^```json/, "").replace(/^```/, "").replace(/```$/, "").trim();
const verdict = JSON.parse(raw); // throws if not valid JSON
verdict.tokens = {
prompt: resp.usage.prompt_tokens,
completion: resp.usage.completion_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:
- The client (lines 11–13). We point the standard
openaiclient atlocalhost:11434/v1with a throwaway API key. From here on the code looks exactly like the OpenAI API — the only difference from the rest of the course is thebase_url. Nothing about being headless changes the model call itself. - Getting the task in:
read_task. This is the headless part. It checks--queryfirst, then falls back to reading stdin only if data was piped in (not sys.stdin.isatty()). Thatisatty()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 blockedread_taskis the classic "my cron job hangs" bug. - Doing the work:
triage. A single model call with a system prompt that demands JSON-only output,temperature=0for repeatability, then three lines of defense before we trust the result: strip a stray```fence,json.loadsit (which throws if the model rambled), and attach the token counts. The model is the easy part; not trusting its formatting is the headless part. - Delivering the answer:
main. Notice the shape of the return values. Success prints the JSON to stdout and returns0. A JSON failure returns2; any other error returns1. 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 between1and2.
Every log(...) call routes to stderr, so the four lines above produce a program whose stdout is only the JSON verdict. The terminal session below shows exactly that split.
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 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:
| Code | Meaning | What the caller should do |
|---|---|---|
0 | Success — valid result on stdout | Consume the JSON, continue the pipeline |
1 | Transient/operational failure (model down, timeout) | Retry later; alert if it persists |
2 | Bad output (model returned non-JSON / failed schema) | Do not retry blindly; route to a human / dead-letter |
3 | Needs review — ran fine but low confidence | Send to a human review queue, do not auto-act |
Collapsing every error into exit code 1 means a cron wrapper retries everything — including a model that reliably returns garbage for one malformed ticket. That ticket gets retried every 5 minutes forever, burning compute and never succeeding. Splitting "transient" (1, safe to retry) from "bad output" (2, never retry, escalate) is the difference between a self-healing job and an infinite 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.
{ok, data, error, meta} envelopeWHY: 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:
- It lets the agent name its own failure. The two custom exceptions,
NeedsReviewandBadOutput, each carry acodeattribute. 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. - 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 writejson.loads(line)once and never special-case "but what if it failed?", because failure is justok: falsewith a populatederror. - It maps each outcome to a stable exit code. The
exceptladder is ordered most-specific first:NeedsReview→ 3,BadOutput→ 2, and the bareexcept Exceptionat the bottom catches everything else as a transient 1. Success falls through to 0. Themeta.exit_codefield 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.
Smaller open models like Mistral-7B miss strict JSON more often than a frontier model — an extra sentence, a trailing comma, a markdown fence. That makes exit code 2 (bad output) a real, frequent branch here, not a theoretical one. Always validate the model's text against your schema and raise BadOutput on failure. Pair this with the structured-output techniques from M04 (prompt for JSON, strip fences, validate, one re-ask) to push the bad-output rate down before it ever reaches the exit code.
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.
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.
WHY: Cron itself does nothing with output or failures — the wrapper turns exit codes into actions
GOTCHA: Cron runs with a near-empty environment; always use absolute paths and set PATH 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
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: install Ollama in the runner, pull the model, then run the agent and let its exit code gate the pipeline.
WHY: The agent becomes a required check — a non-zero exit fails the build automatically
GOTCHA: Pull the model in a step before the agent runs, or the first call times out cold
# .github/workflows/agent-review.yml
name: Headless agent review
on: [push]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Ollama
run: curl -fsSL https://ollama.ai/install.sh | sh
- name: Start Ollama and pull the model
run: |
ollama serve & # background the server
sleep 5
ollama pull mistral # warm the model before the agent runs
- name: Install deps
run: pip install openai
- 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
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.
Guardrails Without a Human
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. A Mistral agent stuck in a tool loop will happily burn an hour of CPU and never stop. A prompt that confuses the model can produce a 10,000-token ramble that pins your one local GPU and blocks every other scheduled job behind it. Nobody is there to hit Ctrl-C, so "the human will catch it" silently becomes "nothing catches it."
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 response gets expensive. These are not nice-to-haves in headless mode — they are the replacement for the human who used to be watching.
kill after N seconds
loop can't spin forever
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.
Encoding the Three Limits
This wraps the agent 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 responses balloon.
WHY: With no human to hit Ctrl-C, these three limits are the only thing that stops a runaway
GOTCHA:
signal.alarm is Unix-only and main-thread-only; on Windows use a watchdog thread or run under a timeout command# guarded_loop.py — three hard limits replace the human circuit-breaker
import signal, sys, json
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
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=4000) -> 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.chat.completions.create(model="mistral", messages=messages)
tokens_used += resp.usage.total_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")
msg = resp.choices[0].message
messages.append({"role": "assistant", "content": msg.content})
if not msg.tool_calls: # loop ends when no tool needed
return {"result": msg.content, "iters": i + 1, "tokens": tokens_used}
# ... (execute tool_calls, append role:"tool" replies) ...
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)}), file=sys.stdout)
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 OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://localhost:11434/v1", apiKey: "ollama" });
class GuardTripped extends Error {}
async function runGuarded(task, { maxSeconds = 60, maxIters = 8, maxTokens = 4000 } = {}) {
// 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.chat.completions.create(
{ model: "mistral", messages },
{ signal: ac.signal } // abort kills the in-flight call
);
tokensUsed += resp.usage.total_tokens;
console.error(`[guard] iter=${i} tokens=${tokensUsed}`);
if (tokensUsed > maxTokens) // GUARD 3: token budget
throw new GuardTripped(`token budget ${maxTokens} exceeded`);
const msg = resp.choices[0].message;
messages.push({ role: "assistant", content: msg.content });
if (!msg.tool_calls) // loop ends when no tool needed
return { result: msg.content, iters: i + 1, tokens: tokensUsed };
// ... (execute tool_calls, push role:"tool" replies) ...
}
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:
- Guard 1 — the timeout (
signal.alarm/AbortController). This is the outermost net, armed before the loop and disarmed infinallyso it never leaks into the next run. It catches the failure mode you cannot predict from the loop body: one model call that simply hangs. Aftermax_seconds,SIGALRMfires (or the request aborts) and raisesGuardTrippedmid-call. - Guard 2 — the iteration cap (
for i in range(max_iters)). A ReAct agent loops "reason → call a tool → observe" until it decides it is done. A confused model can decide it is never done. Bounding the loop with a plainforrange means the worst case ismax_iterscalls, not infinity — the loop literally cannot spin forever. - Guard 3 — the token budget (
tokens_used > max_tokens). Iterations can be few but enormous — a model that starts emitting a 10,000-token wall of text. We accumulatetotal_tokensevery turn and abort the moment the running total crosses the cap, before the next expensive 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.
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.
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.
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.
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"
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.
Lab: A Nightly Log-Triage Agent
Build a headless agent that a cron job runs every night: it reads a log file, asks Mistral to flag anomalies, emits a JSON report, exits with a meaningful code, and the wrapper routes the result. Everything runs locally against Ollama — no cloud, no cost.
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.
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).
Wrap the model 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.
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.
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.
You can pipe a log file into the agent and get exactly one JSON object on stdout; redirecting 2>/dev/null leaves it parseable by jq; the exit code is 0/1/2/3 depending on outcome; the cron wrapper routes each code to the right place; and a runaway input is killed by the timeout instead of hanging the schedule. That is a production-shaped headless agent.
Knowledge Check
Test your understanding of headless execution, the headless contract, automation, and guardrails.