← Pi Agents

The Interface Is Not the Agent

The terminal, print mode, the JSON stream, RPC and the SDK are five ways to drive one agent; what survives a change of interface is the evidence that the agent is a separate layer, and what does not survive is the design rule for extensions.

You have spent thirty chapters mostly in a terminal, and it is easy to come away thinking the terminal is Pi. It is not. The terminal is one way to drive the agent, alongside print mode, JSON, RPC and the SDK. Switching interfaces preserves the underlying agent architecture, but can change how work is submitted, what a person can approve, and how results and failures are reported.

That is a claim about architecture, and this part of the book ends by testing it. Chapters 32 to 35 take the interfaces one at a time. This chapter states what they share, shows that the sharing is real, and names the one place where an interface does leak into what an extension is allowed to assume.

The result

A scripted exchange run through print, JSON and RPC on the shipped binary produced the same final answer. JSON and RPC also exposed the same observed lifecycle-event sequence; print mode did not expose that event stream. The interfaces differed in how they reported failure, signalled completion and accepted further commands. That is the distinction this chapter examines:

same underlying agent behaviour   !=   same external control contract
agent semantics                   !=   driver semantics
Print JSON RPC
A failed response non-zero exit status, message on stderr exit status 0, failure in the event stream in the event stream, while the prompt command itself succeeded
When is it finished the process exits agent_settled, then exit agent_settled, and the process stays up
What accepts the work the command line the command line a response, disposition: "started", before the run begins
How you match a reply not needed not needed by request id

All three modes use the same underlying agent implementation, but their external contracts differ. The table records the observed behaviour of the scripted test, not a guarantee that every failure produces precisely those exit codes and events. A client must implement the contract of the interface it uses; sharing an agent implementation does not make print, JSON and RPC interchangeable.

One agent, five drivers

cli-integration.md states the CLI invariant in its first lines: “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.” The SDK is not a fifth CLI mode: it embeds the agent in an application that can choose its own resource loading, session management and interface. What is shared is the underlying agent machinery, not necessarily every configuration choice.

    flowchart TD
  TUI["Interactive terminal"] --> AGENT["the agent: loop, session, resources, tools"]
  PRINT["Print"] --> AGENT
  JSON["JSON stream"] --> AGENT
  RPC["RPC"] --> AGENT
  SDK["SDK in your process"] --> AGENT
  

What differs between them is the observer, and each observer has its own contract for failure:

Interface Who drives What you observe How failure shows
Interactive a person a transcript, drawn live on screen
Print a script one string a non-zero exit status
JSON a script events, then exit in the stream, not the exit code
RPC a process responses and events success: false, or events
SDK your code typed objects a rejected promise or an event

Every row describes how a driver exposes the agent. That does not imply that all modes produce identical outcomes for every prompt: extensions may request a human confirmation in an interactive or RPC client and deny the same operation when no UI is available. The shared loop and the available interactions are separate concerns.

Running without a CLI interface

The evidence begins in chapters 1 and 37: the agent core can run without Pi’s terminal, JSON or RPC drivers, and without the coding agent’s session manager. agent.subscribe() provides a programmatic way to observe its events. That is still an interface in the general sense; what is optional is the particular CLI presentation and transport layer, not interaction with the agent altogether.

To make that concrete, here are two driver functions for the same Agent API. One draws text as it arrives, roughly like a terminal; the other waits and returns a final text value and simplified status, roughly like print mode. The test uses two separately constructed but identically scripted agents, so it does not run both drivers concurrently over one mutable agent instance.

import type { Agent } from "@earendil-works/pi-agent-core";

// Interface 1: streaming. What a terminal does: draw text as it arrives.
export async function drive_streaming(agent: Agent, prompt: string, draw: (chunk: string) => void): Promise<void> {
	const off = agent.subscribe((e) => {
		if (e.type === "message_update" && e.assistantMessageEvent.type === "text_delta") {
			draw(e.assistantMessageEvent.delta);
		}
	});
	try {
		await agent.prompt(prompt);
	} finally {
		off();
	}
}

// Interface 2: one-shot. What print mode does: no events, the final text and a status.
export async function drive_oneshot(agent: Agent, prompt: string): Promise<{ text: string; ok: boolean }> {
	await agent.prompt(prompt);
	const last = [...agent.state.messages].reverse().find((m) => m.role === "assistant");
	if (!last || last.role !== "assistant") return { text: "", ok: false };

	const text = last.content
		.map((block) => (block.type === "text" ? block.text : ""))
		.join("");
	// This small example treats an ordinary completed answer as success;
	// it is not a replacement for print mode's full exit-status handling.
	return { text, ok: last.stopReason === "stop" };
}

The test builds two identical agents, scripted to look something up and then answer, and drives one with each function:

// The agent did the same thing under both drivers...
assert.deepEqual(shape(a.agent), shape(b.agent));
assert.equal(a.faux.state.callCount, b.faux.state.callCount);
// ...and the only difference is what each driver observed.
assert.equal(drawn.join(""), once.text);
assert.ok(drawn.length > 1, "the streaming driver saw the answer in pieces");

In this controlled test, both agents produced transcripts with the same shape and made the same number of provider calls. One driver delivered the answer in pieces; the other returned it whole. That demonstrates equivalent behaviour for this scripted exchange, not that changing an interface can never change an extension’s decisions or a future model run.

That demonstration is at the agent core. The same claim is also tested on the shipped binary, which is the stronger evidence, because it crosses the process boundary. examples/ch31-interface/binary.test.ts runs one scripted exchange, a tool call and then an answer, through print, JSON and RPC:

  • all three ended with the same final answer;
  • the agent did the same thing under each: JSON mode and RPC produced the same lifecycle events, tool_execution_start and tool_execution_end, turn_end, agent_end and agent_settled, in the same order, and the tool ran in both;
  • what differed was the observer: print gave one string and nothing else, JSON gave events and no command responses, and RPC gave both;
  • in the tested failure case, print exited non-zero, JSON exited zero while reporting failure in its event stream, and RPC accepted the prompt command but reported the model failure through events. These are distinct error-reporting contracts, not proof that failure outcomes can differ only in presentation.

Observed: both tests ran on 1.0.4 with scripted model responses. They support the claim that these interfaces use the same underlying agent pipeline for the exercised task. They do not establish identical behaviour for every setting, flag, interactive decision or failure path. Chapter 32 examines one observable difference: exit-status handling.

Two limits on what “shipped binary” buys, since the phrase is doing work in the paragraph above. The harness resolves the executable through the package’s own bin mapping, so it runs the bundled build that npm exec pi would run — a test in examples/ch31-interface/ asserts that mapping still points under dist/bundle, which is what stops the evidence quietly downgrading to some other module in dist/. And the scripted model is injected by an extension loaded into the child process, so this is a real subprocess with a real configuration directory and real argument parsing, but not a run against a provider.

The demonstrated claim is narrower: for this scripted exchange, the print, JSON and RPC drivers produced the same answer, while JSON and RPC exposed the same observed lifecycle-event order. Print did not expose those lifecycle events. This does not mean that any interface can substitute for any other. Chapter 20’s custom UI can collect a decision that print mode cannot collect, and such a decision can legitimately change the result. No real provider was tested here, so the tests do not establish model-behaviour equivalence across modes.

What survives a change of interface

Ask of any capability: if I switch the interface, does it still work?

Generally available across interfaces Dependent on the interface
The agent loop and tool-execution pipeline Interactive terminal components (ctx.ui.custom())
Session storage and branch navigation APIs Terminal themes, keybindings, overlays and editor widgets
Lifecycle events for clients that expose them How events are delivered, or whether they are exposed at all
Compaction, retry and recovery mechanisms Process lifetime, status codes and response framing
Skills, templates and context resources when configured and admitted Commands and UI interactions provided by the driver
A dialog request in a UI-capable client Whether someone can answer it: TUI and supported RPC clients versus print and JSON

The left column names shared capabilities, not an assurance that every mode activates identical tools and resources or presents all events. The right column includes control capabilities, not merely visual styling. As chapter 20 explains, ctx.hasUI indicates that the active client can support UI interaction; ctx.mode === "tui" is the stricter check for terminal-only components.

The one place the interface leaks

There is a design rule hiding in that table, and extensions.md states it directly: “Keep tool and event behavior independent from rendering so non-interactive modes remain functional.”

The leak is easy to write by accident. A guard that confirms with ctx.ui.confirm() and has no answer for “there is no UI” is not a guard in print mode. A tool whose result only makes sense once a custom component draws it has no result in JSON mode. Chapter 16’s force-push guard refuses when ctx.hasUI is false precisely because the safe behaviour with nobody to ask is to say no.

The rule, stated as a test an extension author can apply:

Keep the policy independent of how it is drawn. If the policy requires a decision from a person, define what happens when that decision cannot be obtained.

The result of a policy check can legitimately differ by interface capability: a confirmed call may proceed in TUI or a supported RPC client, while the same call is refused in print or JSON mode because no confirmation is possible. That is not a rendering bug; it is the policy’s explicit fail-closed path. What must not happen is silently treating an absent UI as consent. This is the separation introduced in chapter 1, applied to an extension that interacts with both the agent and the person using it.

Where the terminal mechanics went

The terminal is a deep subject with real failure modes: modified Enter that a terminal will not report, themes that derive their palette, per-terminal fixes for Ghostty, WezTerm and Windows Terminal, and the component rules for anything you draw with pi-tui. All of that is accurate and documented, and none of it is about the agent. It now lives in Appendix B, and it is there for you if you are building a terminal component or debugging a key that does not arrive.

What you can and cannot claim

Documented: the shared-agent invariant and the SDK being “not a CLI mode” (cli-integration.md), the mode matrix (extensions.md, rpc-extension-ui.md), and the rule to keep behaviour independent from rendering (extensions.md).

Observed: the two-driver test at the agent core and the three-interface test on the shipped binary, both in examples/ch31-interface/, under scripted responses on 1.0.4.

Proposed: the shared-capability/interface-dependency table and the test of whether an extension explicitly handles missing UI capabilities. This framing is the book’s interpretation of the documented separation between tool behaviour and rendering; it is not a rule that all modes must yield the same decision.

Next

The next four chapters take the interfaces in order of how much control each gives you, and what each charges for it.

Chapter 32 is the first of the non-terminal ones: Headless Pi, the same agent with no person attached, invoked once and exited.