Building AI Agents with Open Source Models
Track 7: Production & Deployment OS Track · Bonus Production Module
⏱ 55 min 📊 Advanced
🦎
Open Source Track — M21C Everything here runs against a local Ollama + Mistral-7B with the 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 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 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.

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 an LLM. 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, secretsSupplied 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, runs a small tool-using loop against local Mistral, 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: 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:

  1. The client (lines 11–13). We point the standard openai client at localhost:11434/v1 with 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 the base_url. 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 model call with a system prompt that demands JSON-only output, temperature=0 for repeatability, then three lines of defense before we trust the result: strip a stray ``` fence, json.loads it (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.
  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 lines 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 1840ms, severity=high {"severity": "high", "category": "infrastructure", "summary": "Disk exhaustion on web-03 causing HTTP 500 errors", "tokens": {"prompt": 78, "completion": 41}, "latency_ms": 1840} $ 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 (model down, timeout)Retry later; 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 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.

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 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.

🦎 Local-Model Note

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.

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; 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.

WHAT: A GitHub Actions job that stands up Ollama, 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: 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
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. 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.

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 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.

WHAT: An 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
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:

  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 model 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 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 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 model that starts emitting a 10,000-token wall of text. We accumulate total_tokens every 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.

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 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.

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 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.

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.

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.

Definition of Done

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.

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

A
It runs faster because it skips rendering a UI
B
It uses a smaller model to save memory
C
It runs with no interactive UI and no human in the loop — driven by a program and consumed by a program
D
It can only be triggered by cron, never by an event

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?

A
Cron is corrupting the JSON encoding
B
Log/progress lines are being printed to stdout alongside the JSON, so the parser sees extra text
C
The model is too small to produce JSON
D
Cron cannot capture stdout at all

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?

A
Code 1, because all failures should be treated identically
B
A distinct code like 2 (bad output), so the wrapper escalates instead of retrying a failure that will never succeed
C
Code 0, because the agent technically ran
D
It should print an error and never set an exit code

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?

A
They make the agent produce better answers
B
Interactively a human can hit Ctrl-C on a runaway; headless there is no human, so the limits must be enforced by code
C
They are required by the cron specification
D
They reduce the model download size

5. In the GitHub Actions workflow, why is there a separate step that runs ollama pull mistral before the agent step?

A
The model weights are not present on a fresh CI runner; pulling them first prevents the first agent call from timing out cold
B
GitHub Actions requires every model to be declared in a manifest
C
Pulling the model authenticates the agent to the API
D
It is only needed for the paid tier of Actions

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

A
Writing results to a hard-coded log file
B
Prompting the user for confirmation before printing
C
Printing logs and results both to stdout for visibility
D
Reading the task from stdin and writing only structured JSON to stdout, with logs on stderr
0/6