Drive one agent through three interfaces
The same scripted run through print, JSON and RPC answers identically; the observer differs, and the failure contract differs with it.
The terminal is one of five ways to drive the agent, and switching drivers changes how you watch it, not what it is. Two tiny drivers below put that claim in front of the same agent-core agent, and a shipped-binary test pushes it across print, JSON and RPC. Everything here ran on 1.0.4 under a scripted model — it exercises how each interface observes the agent, not a provider’s output.
Two drivers, one agent
The file below is the chapter’s canonical pair of interfaces over one agent, reproduced exactly:
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") as any;
const text = last.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("");
return { text, ok: last.stopReason !== "error" && last.stopReason !== "aborted" };
}Expected outcome
Two identical scripted agents, driven one way each, produced transcripts of the same shape and the same provider call count; the only difference is what each observer saw — the answer in pieces for the streaming driver, whole for the one-shot driver.
On the shipped binary the same scripted run through print, JSON and RPC ended
with the same final answer, and JSON and RPC produced the same lifecycle
events (tool_execution_start, tool_execution_end, turn_end, agent_end,
agent_settled) in the same order with the tool running in both.
| JSON | RPC | ||
|---|---|---|---|
| What you observe | one string, nothing else | events, no command responses | responses and events |
| A failed response | non-zero exit status, message on stderr | exit 0, failure in the event stream | failure in the event stream; the prompt command itself succeeded |
| When it is finished | the process exits | agent_settled, then exit |
agent_settled, the process stays up |
| How you match a reply | not needed | not needed | by request id |
The agent is the same in all three columns; everything in the table belongs to the driver.
Mechanism and limitations
Documented: cli-integration.md states all modes use the same agent,
sessions, resources and tools and that the SDK is “not a CLI mode”.
Observed: drivers.test.ts (agent core, in-process) and binary.test.ts
(shipped binary, child process) both ran on 1.0.4 against scripted responses,
asserting the same answer, the same event order, the observer differences and
the failure-contract differences above.
The shipped-binary run is a real subprocess with real argument parsing and a real configuration directory, but no provider — the scripted model is injected through an extension. The bounded claim is same agent, same answer, same lifecycle events behind each interface. It is not “any interface substitutes for any other”, it is not a real model’s response, and a confirmation-capable interface is not merely a different observer. The chapter’s rule: if switching the interface changes what an extension decides, the extension has mixed presentation into policy.
Understand this example
Copy this prompt into your AI tool. No code runs here.
Apply this example
Copy this prompt into your AI tool. No code runs here.