← 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 JSON, reconstructs deltas, and correlates nothing because there are no commands. All of that work exists only because of a process boundary you chose.

@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.

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 whole chapter in one line: the process boundary is what you pay for, and it is the only thing the SDK removes.

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 things in that exchange are the SDK’s own gain rather than the protocol’s. prompt() resolves only once Pi has nothing automatic left to do, so the host never chooses a completion event; and message_update arrives as an SDK object that still carries the cumulative partial snapshot the JSON transformation strips.

Reading and writing session state

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

One property is worth reading carefully: session.systemPrompt “is read-only and returns the current effective system prompt, including changes that have not yet been sent to the model. Tool changes are declared to the model before the next request.” It is the effective prompt, not the one you configured — which makes it a debugging tool as much as a control surface.

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, so you never had to choose. In the SDK you do, every time. steer() and followUp() expose the two behaviours directly, and both “return "queued" if the input was queued (including after an extension transformed it), or "handled" if an extension consumed it.”

That two-value return is a documented contract about extension behaviour leaking into your control flow, and it is the same distinction the RPC prompt response makes with data.disposition. If your handler returns "handled", no run started and there is nothing to wait for.

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.”

You can write to that state and nothing will change for the next model request. That is the SDK’s equivalent of the branch-not-the-file lesson, stated in API terms.

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 process, process.cwd() is wherever the server was started, which is almost never where you meant.

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.”

Rebind after a switch, or your next prompt streams into a listener attached to a discarded session — with no error, because the subscription is still live on an object nothing dispatches through any more.

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.

And the MCP extension connects its servers on session_start, so you must call session.bindExtensions(). Forget that and MCP tools simply never arrive — silently, because nothing failed.

Contrast with the previous chapter

The JSON stream gave you everything and charged you for parsing it. The SDK gives you the same events typed, plus the cumulative snapshots, plus the ability to hold the model runtime, the settings, and the session manager as objects you own.

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.