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 |
| 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”. What the second row changes is the observer, and each observer has a different failure contract: a person sees a transcript, --print yields one string and an exit code, JSON yields events and no exit signal for a failed run, RPC yields correlated responses and events, and the SDK yields typed objects. 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
--mode json, or--mode rpcselects 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. A script that relies on TTY detection changes behaviour the moment someone runs it from an interactive shell.
Print mode: the whole interface is one string
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 follows the stop reason. “A final assistant response with an
errororabortedstop reason produces a nonzero exit status.” That is the contract a CI step should branch on.
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.
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 - is parsed as an option instead of as text, and the script quietly sends the wrong thing.
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 \
--model sonnet:high \
--no-session
--tools replaces the default selection entirely rather than adding to it — “name every tool you want.” The default set is read, bash, edit, and write, so the command above is a genuinely narrower agent: it can inspect but not change. That is a review job that cannot damage the working tree.
--no-session uses an in-memory session that is not persisted, which is right for a job you never want to resume. Note the counterpart: because sessions are saved automatically otherwise, a headless run against a real directory writes a session file grouped by that directory.
--offline disables automatic network activity including model catalog refreshes, and is equivalent to PI_OFFLINE=1. The -a / --approve and -na / --no-approve pair is the one-shot trust decision, and security.md is explicit that in print, JSON, and RPC modes there is no prompt to ask with — so you must decide it explicitly.
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 this chapter does not settle
Print mode gives you a string. JSON mode gives you a stream of records and no exit-code signal for a failed run. Neither lets you send a second instruction after the first is accepted — the process is already exiting. That is the boundary of a one-shot integration, and it is a boundary 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.