← Pi Agents

Headless Pi

Running Pi with no person attached — print and JSON modes, how the mode is chosen, what survives without a terminal, and what each mode costs and gives you.

A CI job needs to run a review over a diff. A pre-commit hook needs to summarise what just changed. Neither has a person, and neither wants a terminal.

pi already handles this, and the handling is almost entirely documented in one place: cli-integration.md. But the interesting question is not “how do I print the answer” — it is what is different when the agent has no person attached, because most of what you built in the previous twenty-nine chapters assumed one.

The four modes, and what changes between them

cli-integration.md states the invariant first: “All four modes use the same agent, sessions, resources, and tools. The mode determines how input enters Pi, how output is exposed, and whether the process remains available for more commands.”

Mode Interface Lifetime Use it when
Interactive Terminal UI Until the user exits A person is working with Pi directly
Print Final text on stdout One invocation A script needs the final assistant response
JSON JSONL events on stdout One invocation A process needs structured progress from a run
RPC JSONL commands, responses, and events Long-lived A process needs bidirectional control

That table is the whole chapter in miniature. The last column is a judgement about what your caller needs, not about how powerful Pi is. Everything else is detail.

The shape underneath the table is the chapter’s real content:

    flowchart TD
  CORE["Same agent, sessions, resources, tools"]
  CORE --> A["Interactive"] --> A2["driver: person"]
  CORE --> B["Print"] --> B2["driver: script"]
  CORE --> C["JSON"] --> C2["driver: script"]
  CORE --> D["RPC"] --> D2["driver: process"]
  CORE -.-> E["SDK"] --> E2["driver: your process"]
  

One core, five interfaces, and the same split top.txt records as “who is driving and who is looking”. The interface changes how work is submitted, observed and controlled: a person sees a transcript, --print yields final text and an exit code, JSON yields events, RPC yields correlated responses and events, and the SDK yields typed objects. JSON mode does not ordinarily signal a failed assistant response through its exit status, although an invocation error can still make the process exit nonzero. Interface capabilities can also change an extension’s decisions, such as whether it can request human approval; sharing an agent implementation is not a guarantee of identical outcomes. Note also the dashed edge: cli-integration.md says it plainly — “The SDK is not a CLI mode.” It embeds the agent in a Node.js or Bun process rather than crossing a process boundary, and chapter 34 treats it separately.

Keeping the SDK apart from the four modes is the first thing to get right.

How the mode is chosen when you do not say

This is documented precisely, and it is worth knowing because it means a script can work by accident:

With terminal stdin and stdout, Pi opens the terminal UI unless --print, --mode json, or --mode rpc selects another interface. When either stream is redirected and neither JSON nor RPC mode is selected, Pi uses print mode.

So pi --print "..." is explicit, git diff | pi --print "Review this change" is explicit plus input, and pi --mode json "..." > events.jsonl is explicit plus redirection. But a script that merely pipes something in and captures stdout gets print mode implicitly.

cli-integration.md confirms this in the print section: “When no mode is selected explicitly, non-TTY stdin or stdout also selects print mode. This allows piped input and output without adding --print.”

Be explicit anyway. The same pi "..." command can open the interactive UI when both standard streams are terminals and switch to print mode when a pipeline or redirection changes either stream. A script should select its intended mode instead of depending on the environment in which it is launched.

Print mode “runs the supplied prompts, writes the final assistant text to stdout, and exits.”

pi --print "Summarize the changes in this repository"

Three documented behaviours to build around:

  • Intermediate events are not exposed. If you need to know what Pi did, print mode cannot tell you.
  • Errors go to stderr. stdout carries only the final assistant text.
  • Exit status reflects a failed or aborted final response. “A final assistant response with an error or aborted stop reason produces a nonzero exit status.” A CI step can use that status to detect these failures, but exit status zero is not proof that the generated review was correct, complete or followed every instruction.

One caution about --mode text. cli.md notes that --mode text “does not force one-shot execution when stdin and stdout are terminals” and that you should use --print for that behaviour. The two flags are not interchangeable.

JSON mode: progress, not just a result

JSON mode “writes a session header followed by agent and session events as newline-delimited JSON”:

pi --mode json "Review this repository" > events.jsonl

cli-integration.md insists on the distinction: “This is structured event output, not a single JSON result or a constraint on the format of the model’s response.”

The exit-status rule is different here, and it will bite a CI job. JSON mode: “A failed or aborted assistant response appears in the event stream but does not by itself produce a nonzero exit status. Inspect the events when success or failure matters. Pi still exits nonzero if the invocation throws an error.”

That is a deliberate asymmetry. Print mode reports failure in its exit code; JSON mode reports it in its stream. If your script only checks $?, it will treat a failed review as a successful one.

Two more stream rules, both stated in both cli-integration.md and json.md: stdout is reserved for JSONL and diagnostics go to stderr; and read stdout continuously, because a reader that stops consuming records can stall Pi when the pipe buffer fills. Treat events.jsonl as sensitive output: events may include prompts, tool arguments, responses and file contents. Do not commit or publish it without review. Extensions that write arbitrary text to stdout can also corrupt a JSONL protocol stream; their logging must respect the same separation.

The input forms, and one that stops working

cli.md gives the input table, which applies in every mode except RPC:

Input Behaviour
message Provide an initial prompt
@path Include a text file or image in the first prompt
Piped stdin Prepend its contents to the first prompt
-- Stop option parsing so a prompt can begin with -

@path resolves from the current working directory, and the working directory also controls project configuration, resource discovery, and session grouping — so in a headless run, cwd is not cosmetic. It decides which extensions and context files load. That is a security-relevant fact as well as a functional one, and the final chapter returns to it.

-- deserves a line of its own because its absence is a real bug: a prompt beginning with - may be interpreted as an option or rejected as an unknown option rather than treated as prompt text. Place -- before such an argument so the CLI does not parse it as a flag.

Constraining what the headless agent may do

Options are independent of mode, so you can narrow an unattended run without leaving the CLI:

pi --print "Review this change" \
  --tools read,grep,find,ls \
  --no-mcp \
  --model sonnet:high \
  --no-session

With a list of plain tool names, --tools replaces the default tool selection, so name every tool you want. Pi’s ordinary default is read, bash, edit and write, although settings can change that default. The command above selects the inspection tools read, grep, find and ls and excludes bash, edit and write. This restricts the operations available through that selected tool set; it does not prevent the Pi process or independently running extension code from changing files. The sonnet:high model selector is illustrative: a headless run also needs an available model and usable authentication.

--no-mcp is in that command for a reason, and 1.0.4 is why. Since 1.0.4, --tools does not remove MCP tools: they are kept unless an entry starts with mcp__. Without --no-mcp, an agent directory with MCP servers configured can still expose MCP capabilities, including operations that modify external systems or local files. Note that --tools read,grep,find,ls,'mcp__*' does not substitute for turning MCP off: a pattern naming every MCP tool keeps them registered, and at the default codemode exposure they can remain callable from a script. Entries may be patterns with *, which is how you keep one server and filter the rest: --tools read,bash,codemode,'mcp__radius__*'. To remove built-in MCP tools for a run, use --no-mcp or --exclude-tools 'mcp__*'. The --no-mcp flag disables Pi’s built-in MCP implementation; as chapter 21 explains, it does not disable an extension that replaces that implementation.

What this command does and does not guarantee

Worth stating in three parts, because collapsing them is how an unattended job ends up with a weaker boundary than the flag suggests.

What it does
Selected model-callable tools Narrowed. In this configuration bash, edit and write are not available as model-callable operations. The chapter’s test observed that an attempted bash call failed and the command did not run. It did not prove that every possible file-changing effect is disabled
Built-in MCP support Disabled for this run by --no-mcp. This does not disable third-party extensions that replace the built-in MCP implementation, nor is it an operating-system permission boundary
The Pi process itself Not restricted by these tool-selection flags. They do not reduce the operating-system permissions of Pi, its extensions or child processes, and they do not constrain code that performs an effect without using a model-callable tool

That third row is the one that matters for anything unattended. An extension loaded into the Pi process has the process’s permissions and need not consult the tool list. A skill, by contrast, is instructions and bundled files; it cannot execute its script merely because the model reads those instructions. With bash and other relevant execution tools unavailable, that particular route to running a script is absent, although independently executing extension or host code remains possible. Tool selection narrows model-callable operations. It is not a sandbox or a general containment mechanism.

If you need the third row bounded, that is an operating-system boundary — a container, a micro-VM, a credential the process simply does not hold — and chapter 42 is the chapter on the difference. Read it before relying on this command as the only thing between a model and your working tree.

--no-session uses an in-memory session that is not persisted, which is useful for a job you never intend to resume. It does not make the run side-effect-free or prevent extensions, tools and child processes from writing other files, logs or external state. Without --no-session, Pi normally persists a headless session under the session storage root, grouped by working directory.

--offline disables Pi’s automatic network activity, including model catalog refreshes, and is equivalent to PI_OFFLINE=1; it does not turn a remote model invocation into an offline operation. The -a / --approve and -na / --no-approve pair overrides project trust for one run. Print, JSON and RPC modes cannot show Pi’s built-in trust prompt, but saved decisions, extension decisions and defaultProjectTrust can still determine the outcome when no flag is supplied. For unfamiliar projects, --no-approve avoids trust-gated project resources; it does not prevent untrusted AGENTS.md or similar context files from reaching the model. Use --no-context-files as well when that input should be excluded. Neither option disables user-level extensions or creates a filesystem sandbox.

The fork-and-rebrand footnote

cli-integration.md closes with something worth knowing if you ever ship Pi under your own name. A package.json can set:

{
  "piConfig": {
    "name": "my-agent",
    "configDir": ".my-agent"
  }
}

Changing the top-level bin field sets the executable name. These settings “affect the CLI banner, configuration paths, and derived environment variable names” — so a rebrand changes where settings and sessions live, not just what the program calls itself.

What was run

The tests reported in examples/ch32-headless/ invoke the shipped pi binary as a child process. A small extension registers the faux provider as a model and takes its script from an environment variable, exercising CLI parsing, mode selection and process exit codes without external model credentials. These tests are evidence for the observed cases below; they do not exercise every trust setting, extension side effect or MCP-replacement configuration described in this chapter.

  • print mode writes only the final text to stdout and exits 0, and a failed final response exits non-zero with the message on stderr and nothing on stdout;
  • JSON mode reports the same failure in the event stream and exits 0, so a script that checks only $? calls a failed review a success;
  • with stdin and stdout redirected and no mode given, Pi chose print mode;
  • piped stdin was prepended to the first prompt, @notes.md put the file’s content in the first prompt, and -- let a prompt begin with a dash;
  • with --tools read,grep,find,ls, a model that asked for bash got a failed tool result and the command did not run; with the default tools it did;
  • --no-session wrote no session file, and without it a run wrote one under the agent directory.

These observations support the listed examples on the tested build. The model is scripted, so the tests show how Pi handles known responses, not whether a real model follows a review instruction or whether the example’s tool selection constitutes an enforceable security boundary. In particular, the reported tests do not establish the behavior of --no-mcp with a replacement MCP extension or the effect of --no-approve on context-file admission.

What this chapter does not settle

Print mode gives you final text. JSON mode gives you a stream of records; a failed assistant response is reflected in the stream rather than necessarily in the process exit status, although an invocation error can still exit nonzero. Neither mode accepts a new instruction after startup. They execute the prompts supplied for that invocation and then exit. That is the boundary of a one-shot integration, and it is by design: cli-integration.md says the JSON-mode process “streams events for that run and then exits; it does not accept later commands.”

The next chapter takes the stream apart record by record, because the next decision is not whether to go headless but what you will build on top of the records.

Next: The JSON Event Stream — the framing rules, the event vocabulary, and the reconstruction contract.