spawnagent.sh

What It Means to Spawn an AI Agent From the CLI

6 min read

"Spawn an agent" sounds exotic, but from your shell's point of view it means starting a process. That process happens to call a language model in a loop and use tools on your machine. Holding both views at once lets you run agents the way you run any other long job: with defined inputs, captured outputs, a known exit status and a deadline.

Four parts: model, loop, tools, context

Anthropic's engineering team draws a useful line between workflows, "systems where LLMs and tools are orchestrated through predefined code paths," and agents, "systems where LLMs dynamically direct their own processes and tool usage." The same essay on building effective agents notes that agents are "typically just LLMs using tools based on environmental feedback in a loop."

Break that down and you get four parts:

  • The model reads everything it has been given and decides the next step: answer, or call a tool.
  • The loop is the runner program around the model. It sends the context, executes whatever tool the model asks for, appends the result and asks again.
  • The tools are the actions the runner allows: reading and editing files, running shell commands, fetching URLs, calling APIs.
  • The context is everything in the model's view: instructions, the task, the files it has read, the output of every tool call so far. It is bounded by the model's context window.

In pseudocode, the whole loop fits in a few lines:

context = [instructions, task]
repeat:
    reply = model(context)
    if reply is a final answer: stop
    result = run_tool(reply.tool_call)
    context = context + [reply, result]

Everything interesting about agents lives in that repeat. It can run three times or three hundred, which is why the same essay recommends "stopping conditions (such as a maximum number of iterations) to maintain control."

Spawning is starting a process

When a script or another agent spawns an agent, the parent makes the same decisions it makes for any child process:

  • Arguments: the task and the flags that shape the run.
  • Working directory: what the agent sees by default and where its edits land.
  • Environment variables: anything in the environment, including credentials, is readable by the agent's tools.
  • Standard streams: where stdin comes from and where stdout and stderr go.
  • Process group: so that stopping the agent also stops the test runners and servers it started.

The parent owns the lifecycle. The agent decides what to do inside the run. The parent decides when it starts, what it can see, and when it has run long enough.

The lifecycle of one run

A headless run moves through four stages.

Start. The CLI parses flags and loads configuration. This stage is easy to overlook, and it matters. As of September 2026, Claude Code's headless documentation says that claude -p loads the same context an interactive session would, including configuration in the working directory, unless you pass --bare. Without it, a -p run also executes hooks from the project's .claude/settings.json and connects the servers listed in its .mcp.json, with no trust prompt. The folder you start the agent in is part of its input. The docs recommend --bare for scripted calls, but note that bare mode skips your subscription login and expects an API key in the environment.

Loop. The model and tools take turns. Tool calls may start their own child processes: a test suite, a build, a dev server.

Finish. The agent produces a final result, writes it to stdout and exits with a status code.

Stop early. If you signal the process, the details depend on the tool. The same Claude Code page says that on SIGTERM it stops any shell command still running, exits with code 143 and leaves the in-progress turn unfinished, and that sending SIGINT ends the turn instead. Check your agent's documentation for the equivalent behavior. A SIGKILL cannot be caught, so it skips that cleanup entirely: after any forced stop, look for leftover child processes.

Interactive and headless runs

An interactive run has a terminal and a person. The agent can ask for permission, you can steer it mid-task, and a stall is obvious because you are watching.

A headless run has neither. It takes a task, runs to completion and exits, which is what you want for scripts, CI jobs and fan-out. The trade is that every decision a person would have made must be made before the run starts. The Claude Code docs describe how, in a -p run with no one available to answer, requests that would need a permission prompt are denied rather than left waiting. A headless agent that needs a permission you did not grant will fail partway through, so decide what it may do before you launch it.

A basic headless call with structured output looks like this:

claude -p "Summarize the failures in test-output.txt and propose a fix" \
  --output-format json > result.json

jq -r '.result' result.json        # the agent's final answer
jq -r '.session_id' result.json    # an ID you can resume later

With JSON output, the payload also includes a total_cost_usd figure, which the docs describe as a client-side estimate that can differ from your bill.

Inputs, outputs and logs

Treat each run as a unit you can inspect afterward.

Inputs arrive through the prompt argument, stdin, files in the working directory and environment variables. Large inputs belong in files the agent reads, not in a giant argument. Claude Code, for example, currently caps piped stdin at 10MB and tells you to reference a file path for anything larger.

Outputs come in three forms. Stdout carries the result, ideally as JSON your script can parse. Stderr carries diagnostics. For coding agents, the most important output is the change on disk, so capture git diff and git status after every run.

Logs are what you will want when a run goes wrong. Streaming formats such as Claude Code's stream-json emit newline-delimited JSON events while the agent works, which gives you a complete transcript to keep. A simple layout is one directory per run:

runs/0042-fix-flaky-test/
  prompt.txt
  result.json
  stderr.log
  changes.diff
  status.txt
  exit_code

Exit codes: what they tell you and what they do not

The exit status is the first thing a script should check. Claude Code exits with 0 on success and a non-zero code when the run fails. Its docs add a subtlety: an invalid flag is reported on stderr before the run starts, but a failure inside the run, such as missing authentication, is printed as the result on stdout. Read both streams.

Some codes come from outside the agent. When a process ends on a fatal signal, Bash uses 128 plus the signal number as its exit status.

StatusUsually means
0The agent finished its loop
124GNU timeout stopped it at the deadline
137It was killed with SIGKILL (128 + 9)
143It was stopped with SIGTERM (128 + 15)
Other non-zeroThe agent or CLI reported an error

Remember what a zero does not mean. It tells you the loop ended cleanly, not that the task was done correctly. An agent can exit 0 after writing code that fails its own tests. Verify the result separately, with the same checks you would apply to a person's work.

Timeouts

Agents stall in ordinary ways: a network call that never returns, a test suite that hangs, a loop that keeps trying variations of a fix that will not work. Turn limits and budgets inside the agent help, but a wall-clock deadline enforced from outside is the backstop that always works.

GNU coreutils ships timeout for this. It sends TERM when the deadline passes, and -k adds a KILL after a grace period if the process ignores TERM. When the command times out, timeout exits with 124, so your script can tell a deadline from a failure. Here is a small wrapper that puts the pieces together:

#!/usr/bin/env bash
set -u
run_dir="runs/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$run_dir"
cp prompt.txt "$run_dir/prompt.txt"

timeout -k 30s 20m \
  claude -p "$(cat prompt.txt)" --output-format json \
  > "$run_dir/result.json" 2> "$run_dir/stderr.log"
status=$?

echo "$status" > "$run_dir/exit_code"
git diff > "$run_dir/changes.diff"
git status --short > "$run_dir/status.txt"   # also lists new, untracked files

case "$status" in
  0)   jq -r '.result' "$run_dir/result.json" ;;
  124) echo "timed out after 20 minutes" >&2 ;;
  *)   echo "agent exited with status $status" >&2 ;;
esac
exit "$status"

Pick the deadline from how long the task should take, not from how long you are willing to wait, and keep the grace period long enough for the agent to finish writing its output. Once every run leaves a prompt, a result, a diff and an exit code behind, spawning an agent stops being a leap of faith and becomes a job you can audit.

More from spawnagent.sh