← Pi Agents

SDK Sessions

Embedding Pi in a Node.js or Bun process — createAgentSession, the injected boundaries, session replacement, and the rules that keep an in-process agent well behaved.

Your integration is written in TypeScript. It parses JSONL records and reconstructs streamed messages. If you use RPC, it also correlates command responses with requests. Much of that protocol handling exists because you chose to communicate with Pi across a subprocess boundary.

@earendil-works/pi-coding-agent embeds Pi directly, and sdk.md opens with the recommendation in its first two lines: use the SDK for in-process TypeScript integration; for a language-independent or isolated subprocess, use CLI integration instead. This chapter is about what the boundary buys you back and what it charges.

Who owns what

createAgentSession() takes over several responsibilities at once, and knowing which is yours matters for disposal and for failure handling.

Object You create it Owns You must
SessionManager optionally — the default is SessionManager.create(cwd) the session file, and the active branch nothing, unless you replace it
SettingsManager optionally — defaults to reading global and project settings effective settings nothing, unless you override
ResourceLoader optionally — defaults to DefaultResourceLoader discovery of context files, skills, prompts, themes nothing, unless you want different admission (chapter 28)
AgentSession by calling createAgentSession() the agent, its tools, extensions, subscriptions dispose() it
The Agent inside never directly messages, tool execution, the loop nothing; the session owns it

The session row has the essential lifecycle obligation: call session.dispose() when finished. It aborts active work and releases the session’s listeners and extension contexts. If your host also creates external resources such as its own file watchers, sockets, or model runtime, it remains responsible for their lifecycle; disposing an agent session is not a general-purpose cleanup mechanism for host-owned resources.

Two consequences of the defaults are worth knowing before you rely on them. The default SessionManager persists session history to disk; pass an in-memory manager if you do not want a session file, as with --no-session in chapter 32. Subscriptions are attached to an AgentSession, not directly to its SessionManager. When AgentSessionRuntime replaces the active session, rebind listeners to the new AgentSession; branch-relative state must still be reconstructed from the selected session and branch, as chapter 5 explains.

The whole API in twelve lines

The minimal session is genuinely this small:

import { createAgentSession } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession();

try {
  await session.prompt("What files are in the current directory?");
  console.log(session.getLastAssistantText());
} finally {
  session.dispose();
}

createAgentSession() “uses the working directory, discovered resources, stored settings, and configured credentials.” prompt() resolves when the run finishes, including automatic retries.

Compare that with the previous two chapters. No LF splitting, no jq, no framing rule, no process to supervise. The events still exist — session.subscribe() delivers them — but they arrive as typed objects.

The central trade-off is this: the SDK removes the need for a subprocess protocol and gives the host direct, typed access to session objects, but it also puts the agent inside the host’s process. The API and lifecycle obligations differ from those of JSON or RPC clients.

That trade-off matters for chapter 42’s security argument. The SDK shares a process and operating-system identity with the host: extensions and other code it loads may have access to the host’s files, credentials, memory, and network permissions. You can dispose of one AgentSession and create another without restarting the host, but that is not equivalent to killing and restarting an isolated Pi subprocess. The in-process SDK provides no separate OS permission boundary for Pi. For a separately supervised process with independently restricted credentials and permissions, use a subprocess arrangement such as chapter 32’s CLI integration.

Streaming, in process

const unsubscribe = session.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

try {
  await session.prompt("Explain this repository");
} finally {
  unsubscribe();
}

Two documented details. Subscribe before prompting — sdk.md says so directly, because a fast completion can arrive first. And note what is absent from the wire chapter’s burden: event.assistantMessageEvent here is the SDK type, which carries the cumulative partial snapshots the JSON transformation strips. That is the single most concrete thing the SDK gives back.

The lifecycle semantics are unchanged, and sdk.md repeats them: message_end contains the authoritative completed message, agent_end marks the end of one low-level run but “automatic recovery or queued work can still follow,” and agent_settled is the one to use when the host needs to know Pi will not continue automatically.

The whole lifecycle in one exchange, with no pipe and no framing between the two participants:

    sequenceDiagram
  participant H as Host process
  participant S as AgentSession
  participant L as Agent loop
  H->>S: subscribe(handler)
  H->>S: prompt("...")
  S->>L: enter the active branch
  L-->>S: message_update events
  S-->>H: typed events, with partial
  L-->>S: agent_end
  L-->>S: agent_settled
  S-->>H: prompt() resolves
  H->>S: dispose()
  

Two conveniences belong to the SDK rather than the wire protocol. For an accepted agent run, awaiting prompt() waits through its automatic continuation and retry processing; a separate event-driven host may instead use agent_settled as its completion boundary. And message_update arrives as a typed SDK event retaining cumulative partial snapshots that the JSON wire transformation strips. A resolved promise establishes completion, not necessarily a successful final assistant response; inspect the final message’s stop reason when success matters.

Reading and writing session state

AgentSession exposes session.messages, session.model, session.thinkingLevel, session.systemPrompt, and session.getActiveToolNames().

One property needs particular care. sdk.md describes session.systemPrompt as read-only and representing the current effective system prompt, including changes not yet sent to the model. But the book’s 1.0.4 test found an important limit: a named prompt section supplied by before_agent_start (chapter 18) reached the provider while this accessor still reported the base prompt after the run. Therefore use session.systemPrompt to inspect Pi’s exposed prompt state, not as proof of the exact system content sent on a particular request. Request-level observation is required for that stronger claim; tool declarations may also be updated before the next request.

Prompting has a rule the CLI hides

This is the sharpest behavioural difference between in-process and out-of-process, and it is an API decision rather than a protocol one.

A prompt sent while the session is already streaming must specify whether it should steer the current run or follow it. Calling prompt() without that choice rejects rather than guessing.

In the terminal, Enter steers and Alt+Enter follows. In the SDK, when a run is already streaming, you must choose whether a new prompt() steers or follows; an ordinary prompt to an idle session does not require that choice. The explicit steer() and followUp() methods expose the two queue behaviours directly. They return "queued" when Pi queues the input (including transformed input), or "handled" when an extension consumes it.

That two-value return is a documented contract about extension input handling. "handled" means the input handler consumed the message; it does not prove that the handler performed no other operation or started no model work of its own. RPC prompt reports a related disposition and also has a "started" state when it accepts a new run. Handle those states explicitly instead of treating every successful submission as a newly started agent run.

prompt() also handles extension commands and expands file-based prompt templates before ordinary user messages enter the agent — the same expansion rpc-commands.md documents for steer and follow_up.

Two more methods: abort() stops the active operation and waits for idle, and waitForIdle() waits without aborting.

Session storage is a boundary too

SessionManager “owns the persisted or in-memory entry tree and tracks its active leaf. Branching changes that leaf without deleting abandoned branches.”

import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
});

The rule that causes real bugs: “SessionManager is authoritative for finalized model context. Restore external history by constructing the session with a manager containing those entries. Assigning session.agent.state.messages does not replace persisted context.”

The chapter’s test confirmed the narrower rule: replacing session.agent.state.messages with forged history did not replace the finalized context reconstructed from SessionManager on the next request. Do not mutate that internal state as a history-restoration API. Such mutation can still make in-memory observations inconsistent, even when the session manager remains authoritative for the next reconstructed request.

cwd “selects the workspace used for project resource discovery, context files, session grouping, and built-in tool paths. Pass it explicitly when the target differs from process.cwd()”. In a server, the launch directory may differ from the workspace associated with a request or tenant; pass a deliberate cwd rather than assuming the server’s process directory is appropriate.

session.dispose() “aborts active work, invalidates extension contexts, disconnects from the agent, and removes event listeners.” Every example in sdk.md wraps its work in try/finally for exactly this reason.

Replacing the session

AgentSessionRuntime adds newSession(), switchSession(), fork(), and importFromJsonl(). Each “replaces the active AgentSession and recreates services for the target working directory.”

The consequence is the one that bites: “After a runtime replacement, subscriptions belong to the old AgentSession and must be rebound.”

After a switch, subscribe to the replacement AgentSession and release any old subscription handles as appropriate. A listener attached to the former session does not automatically observe events from the new one; depending on how the runtime disposed the old session, that listener may already have been removed. The failure to avoid is missing new-session events, not a guarantee that an abandoned listener remains live.

The injected boundaries

Without overrides the factory creates a ModelRuntime, file-backed SettingsManager, persistent SessionManager, DefaultResourceLoader, and the configured default tools. Each can be supplied explicitly:

  • modelRuntime, model, thinkingLevel, scopedModels — model access and selection.
  • settingsManager — merged settings or an in-memory configuration.
  • sessionManager — persistent or in-memory conversation history.
  • resourceLoader — extensions, skills, prompt templates, themes, context files.
  • tools, noTools, excludeTools, customTools — the active tool set.

sdk.md draws the line clearly: “Use DefaultResourceLoader when you want standard discovery with selected overrides. Supply a custom ResourceLoader when the host owns resource storage and discovery completely.” Most integrations want the former, and reach for the latter too early.

One asymmetry with the CLI

This is the detail that catches people building SDK hosts today: “The CLI loads codemode, tool_search, and MCP as built-in extensions. SDK sessions do not.”

To get them you add createCodemodeExtension(), createToolSearchExtension(), and createMcpExtension() to extensionFactories. codemode and tool_search are registered inactive — enable them through defaultTools with ["+codemode", "+tool_search"] to keep the other default tools, or let the MCP extension activate them: codemode for servers with codemode exposure, tool_search for servers with deferred exposure.

The MCP extension connects its servers on session_start. For this explicit SDK setup, build and reload the configured DefaultResourceLoader, create the session with that loader, and call session.bindExtensions() so extension lifecycle hooks run. Merely naming createMcpExtension() in a factory does not demonstrate a successful connection: check loader diagnostics and the registered tools, and treat missing MCP tools or connection errors as setup failures to investigate.

The reverse is new in 1.0.4. DefaultResourceLoader takes disabledBuiltinExtensions, a list of built-in extensions not to load “even when settings or -e enable them”, so a host can keep mcp out of a session without asking the user to edit settings.json. That is the SDK equivalent of --no-mcp, and it composes the same way the flag does: it disables the built-in extension, not an extension that replaces it.

What was run

examples/ch34-sdk/ exercises selected claims against a real AgentSession with a scripted model. The tests cover streaming, queue selection, session-manager context reconstruction, in-memory storage, and disposal. The default-credential snippets (createAgentSession() with no arguments) are marked documented, not run, because they need a configured provider. These tests do not by themselves verify MCP connections, built-in extension activation, runtime session replacement, or the behavior of a real model.

  • subscribing before prompting delivers the answer as several typed text_delta events, and getLastAssistantText() returns the whole answer;
  • a prompt sent while the session is streaming, with no behaviour given, rejects. steer() and followUp() both return "queued", and the run finishes cleanly;
  • SessionManager is authoritative. The test overwrote session.agent.state.messages with a forged history, prompted again, and inspected what the provider received: the forged text never arrived and the real earlier question did. Assigning to the in-memory transcript changed nothing about the next request;
  • an in-memory session manager writes no file;
  • after dispose(), a listener receives nothing further.

Contrast with the previous chapter

The JSON stream exposed the documented wire events and charged you for framing and reconstruction; it was not a complete copy of every in-process value. The SDK provides typed events, cumulative streaming snapshots, and direct access to the model runtime, settings, and session manager. Those benefits are specific to in-process integration, not evidence that every interface has identical capabilities.

What it costs: language independence and process isolation. Every claim in the extensions chapter about running “inside the Pi process with the same operating-system permissions” applies here with full force — in the SDK, it is literally your process. session.dispose() is not optional; a leaked session is a leaked agent with live event listeners and provider connections.

That is the trade the last chapter of this progression makes the other way: keep the isolation, cross the boundary, and drive a process you started rather than one you are inside.

Next: RPC — a live Pi subprocess, JSONL in both directions, and the command set to drive it.