Learn Agents from Pi
中文
On this page

13. One Kernel, Many Shells

Once the agent kernel is finished, you'll want to add more entry points: an interactive CLI, a one-shot print mode, a JSON event mode, an SDK, RPC, a TUI. Don't write a separate agent for each entry point. Every shell should subscribe to the same kernel event stream and differ only in its input/output protocol.

How shells differ

An interactive TUI cares about the editor, keyboard shortcuts, history, and the status line. Print mode cares about "give me a final answer and signal success or failure with the exit code." JSON mode cares about stdout discipline: every line is a machine-parseable event, with no human-readable logs mixed in. The SDK cares about a composable API. RPC cares about request ids, connection lifecycles, and concurrent sessions.

Real systems typically select the mode with a flag or by inspecting the runtime environment: if there is no terminal (stdin/stdout are redirected) default to print, an explicit --json selects the event stream, --rpc selects the bidirectional protocol, and otherwise enter the interactive TUI. The same session-resumption flags (--session, --continue, --resume) mean the same thing in every mode. These are all product-layer differences and should not enter the agent loop. The kernel only emits events, receives user input, and maintains state.

Stdout discipline

Machine-readable modes are the easiest to break. Print a single debug line to stdout and downstream parsers fail. Suggested rules:

  • In JSON mode, stdout emits JSONL events only.
  • Human logs, debug output, and warnings go to stderr.
  • Every event carries a type, a timestamp, and an optional id.
  • The final result has an explicit terminating event.
  • Exit codes follow a convention: 0 for success, 1 for error, and the corresponding signal code when terminated by a signal. CI scripts judge success and failure by the exit code.

This discipline shapes your entire code structure. It is also why Chapter 10 insists that "tool output is returned through events rather than printed with console.log": any tool that prints directly to stdout will pollute the JSON stream. Tool output must travel through events, and the shell decides how to display it.

The SDK is not a leak of internal objects

The SDK should not expose internal state to users as-is. It should provide stable methods:

type AgentSession = {
  prompt(text: string): Promise<void>;
  steer(text: string): Promise<void>;
  followUp(text: string): Promise<void>;
  abort(): void;
  subscribe(listener: (event: AgentEvent) => void): () => void;
};

If SDK users can mutate the messages array directly, you can no longer maintain consistency across the log, compaction, and events. When advanced capabilities are needed, expose them through explicit APIs too — for example setModel, setActiveTools, compact.

RPC and the full command surface

RPC is the most complete control plane of all the modes, well suited to editor plugins and remote drivers. It usually runs as JSONL over stdio: one side sends commands, the other returns events and responses. The key is correlating requests with events:

{"id":"1","type":"prompt","message":"Fix lint"}
{"id":"1","type":"response","command":"prompt","success":true}
{"type":"turn_start"}
{"type":"tool_execution_start","name":"bash"}
{"type":"agent_end"}

A mature RPC surface exposes far more than prompt: steer, follow_up, abort, switch model, switch reasoning effort, switch queue mode, trigger compaction, run bash directly, export HTTP, fork, clone, switch sessions, and query state, messages, and the session tree. Each command maps one-to-one to a kernel method; the RPC layer only serializes and correlates requests — it does not reinterpret model messages.

One tradeoff worth noting: an RPC surface cannot offer the rich components that only an interactive TUI has (custom footers, inline editors). But it can support request-response dialogs — when an extension wants to pop up a "select model" chooser, the RPC surface emits a UI request event, the client renders it, and returns the result. This way an extension's interaction needs can be met even in a terminal-less environment; only the presentation is left to the client.

The TUI is a projection of the event stream

A terminal UI looks complicated, but at its core it is still a projection of events. Assistant text deltas update the text block, tool events update the tool rows, queue events update the status bar, session events update the history tree. The TUI should not decide whether the agent's next step is a tool call; it only displays kernel facts and collects user input.

The real engineering difficulty of a TUI lies in rendering, not logic. These details are worth knowing even if a teaching project doesn't implement all of them:

  • Differential rendering: cache every line of the previous frame and repaint only the lines that changed. Streaming text updates dozens of times per second, and a full-screen repaint flickers into a blur.
  • Synchronized output: use the terminal's synchronized-update escape sequences to commit multiple changes within a frame as a batch, avoiding tearing.
  • Character width: the display width of CJK, emoji, and combining characters is not equal to string length, so wrapping and cursor positioning must be computed by grapheme cluster.
  • Per-tool custom rendering: edit shows a highlighted diff, bash shows expandable live output annotated with the exit code, and search results render as a table. These renderers are supplied by the tool definitions, and the UI schedules them.
  • Streaming arguments: when tool call arguments are shown as they stream in, diff computation must be deferred until the arguments are complete (message_end); otherwise you compute a wrong diff against half an argument.

Keeping this complexity inside the TUI layer is exactly the value of a unified event stream: JSON mode and the SDK don't need to understand grapheme width to receive the same domain facts. If the TUI needs private state to work, the other modes will quickly fall behind.

Exercise

Implement two shells:

  • tiny-agent -p "task": print mode, outputting only the final answer.
  • tiny-agent --json -p "task": JSONL mode, outputting events.

Acceptance criteria:

  • Both modes use the same agent kernel.
  • In JSON mode, stdout contains no non-JSON content, and human logs go to stderr.
  • Tool output is delivered through events, not printed directly by tools.
  • In print mode, a tool error yields a non-zero exit code or an explicit error event.
  • Both modes support --session to resume the same session.
  • Events the SDK subscribes to have the same semantics as JSON-mode events.