pi Documentation

AI agent toolkit: unified LLM API, agent loop, TUI, coding agent CLI

117,090 stars TypeScript TypeScript 1 file · ~128,238 tokens
RAW Doc

Technical Documentation: earendil-works/pi

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See CONTRIBUTING.md.

Pi

Pi is a minimal, extensible agent harness that you can make your own.

Adapt Pi to your workflows, not the other way around. Customize Pi with extensions, skills, prompt templates, and themes. Bundle them as Pi packages and share via npm or git.

Pi ships with powerful defaults but skips features like sub-agents and plan mode. Ask Pi to build what you want, or install a package that does it your way.

Use Pi interactively, automate it in print or JSON mode, control it over RPC, or build apps with the Pi TypeScript SDK. See OpenClaw for a real-world integration.

Getting started

Install the command-line interface:

bash
curl -fsSL https://pi.dev/install.sh | sh

On Windows:

shell
powershell -c "irm https://pi.dev/install.ps1 | iex"

The installer pins all dependencies and updates Pi with pi update. Alternatively, install directly with npm, which does not pin transitive dependencies:

bash
npm install --ignore-scripts @earendil-works/pi-coding-agent

Pi requires Node.js 22.19 or newer. The macOS, Linux, and Windows installers can install it if needed. Pi does not require dependency lifecycle scripts for a normal npm installation.

Start Pi in the directory where you want it to work:

bash
cd /path/to/project
pi

For a built-in AI provider, run /login inside Pi to connect a subscription or API key. Then give Pi a task.

See the documentation for full setup and usage instructions, or visit pi.dev for demos.

Run with Nix

bash
nix run github:earendil-works/pi/stable

stable points at the latest release. Install it with nix profile add github:earendil-works/pi/stable and update with nix profile upgrade pi. Use a release tag such as github:earendil-works/pi/v1.0.0 to pin a version, or github:earendil-works/pi for unreleased changes on main. Nix builds Pi from source.

Supports ARM64 and x86-64 on Linux and macOS. Use nix build . or nix run . to build or run your checkout.

Nix builds are offline, so the bundled model data comes from a pi.dev model catalog revision pinned in nix/model-catalog.json. At runtime, Pi still overlays newer catalog data from pi.dev as usual. The Nix workflow replaces the pin on main when it no longer matches the checkout, for example after a provider is added or gains a new model type. To refresh it by hand:

bash
npm run update:model-catalog-pin

Packages

This monorepo contains the Pi CLI and its supporting libraries.

Package Description
@earendil-works/chord Typed host runtime for hot-reloadable plugins, contracts, replicated state, and RPC over JSON links
@earendil-works/pi-telemetry Vendor-neutral telemetry contracts, reference adapter, conformance tests, and typed schemas
@earendil-works/pi-ai Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.)
@earendil-works/pi-durable Durable conversation, task, and document runtime
@earendil-works/pi-agent-core Agent runtime with tool calling and state management
@earendil-works/pi-coding-agent Interactive coding agent CLI
@earendil-works/pi-tui Terminal UI library with differential rendering

For Slack/chat automation and workflows see earendil-works/pi-chat.

Permissions & Containerization

Pi does not include a built-in permission system for restricting filesystem, process, network, or credential access. By default, it runs with the permissions of the user and process that launched it.

If you need stronger boundaries, containerize or sandbox Pi. See packages/coding-agent/docs/containerization.md for three patterns:

  • Gondolin extension: keep pi and provider auth on the host while routing built-in tools and ! commands into a local Linux micro-VM.
  • Plain Docker: run the whole pi process in a local container for simple isolation.
  • OpenShell: run the whole pi process in a policy-controlled sandbox.

Contributing

See CONTRIBUTING.md for contribution guidelines and AGENTS.md for project-specific rules (for both humans and agents). Longer term plans for Pi can also be found in RFCs.

Development

bash
npm install --ignore-scripts  # Install all dependencies without running lifecycle scripts
npm run build         # Refresh model data, then build all packages
npm run build:offline # Rebuild using existing model data without network access
npm run check         # Lint, format, and type check
./test.sh            # Run tests (skips LLM-dependent tests without API keys)
./pi-test.sh         # Run pi from sources (can be run from any directory)

Using local packages outside the monorepo

Build every public package into one coherent local artifact set:

bash
npm run pack:packages -- --out .artifacts/pi-packages

This refreshes model data before building pi-ai. To avoid network access when
model data is already hydrated, pass --offline-model-data.

Then configure an external project to consume one package and resolve all of
its Pi dependencies from the same artifact set. npm is the default:

bash
node scripts/use-local-packages.mjs \
  --manifest .artifacts/pi-packages/manifest.json \
  --consumer ../my-project \
  --package @earendil-works/pi-durable \
  --package @earendil-works/pi-agent-core
cd ../my-project
npm install --ignore-scripts

For a pnpm project, point --consumer at the workspace root:

bash
node scripts/use-local-packages.mjs \
  --manifest .artifacts/pi-packages/manifest.json \
  --consumer ../my-project \
  --package @earendil-works/pi-agent-core \
  --package-manager pnpm
cd ../my-project
pnpm install --ignore-scripts

Repeat --package for each direct dependency. The command updates the
consumer's package.json with content-addressed local file: references. It
writes transitive overrides to package.json for npm or pnpm-workspace.yaml
for pnpm. Keep the artifact directory available while installing or updating
the consumer. Re-run both commands after changing Pi source.

Building standalone binaries from release source

GitHub releases include a versioned source archive covered by the release's SHA256SUMS file. Extract it and run the same build script used for the official standalone binaries:

bash
VERSION="<release-version>"
tar -xzf "pi-${VERSION}-source.tar.gz"
cd "pi-${VERSION}"
./scripts/build-binaries.sh --offline-model-data --platform linux-x64 --out "$PWD/out"

The archive includes release model data and native prebuilds. --offline-model-data uses that model data without refreshing provider catalogs. The script installs dependencies and builds the executable with its runtime assets; pass --skip-install if dependencies are already provided.

Supply-chain hardening

We treat npm dependency changes as reviewed code changes.

  • Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged.
  • .npmrc sets save-exact=true and min-release-age=2 to avoid same-day dependency releases during npm resolution.
  • package-lock.json is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless PI_ALLOW_LOCKFILE_CHANGE=1 is set.
  • npm run check verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent install lock.
  • The pi.dev installer installs from packages/coding-agent/install-lock/, generated from the root lockfile, to pin transitive deps. The npm package does not pin transitive deps.
  • Local release smoke tests and npm publication use the same tarball packer; npm publishes the validated tarballs rather than repacking workspace directories.
  • Local release installs, documented npm installs, and pi update --self use --ignore-scripts where supported.
  • CI installs with npm ci --ignore-scripts, and a scheduled GitHub workflow runs npm audit --omit=dev plus npm audit signatures --omit=dev.
  • Install lock generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed.

Share your OSS coding agent sessions

If you use Pi or other coding agents for open source work, please share your sessions.

Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks.

For the full explanation, see this post on X.

To publish sessions, use badlogic/pi-share-hf. Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and pi-share-hf.

You can also watch this video, where I show how I publish my pi-mono sessions.

I regularly publish my own pi-mono work sessions here:

License

MIT


Overview (`packages/agent/README.md`)

@earendil-works/pi-agent-core

Stateful agent with tool execution and event streaming. Built on @earendil-works/pi-ai.

Installation

bash
npm install @earendil-works/pi-agent-core

Quick Start

typescript
import { Agent } from "@earendil-works/pi-agent-core";
import { createModels } from "@earendil-works/pi-ai";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";

const models = createModels();
models.setProvider(anthropicProvider());
const model = models.getModel("anthropic", "claude-sonnet-4-6");
if (!model) throw new Error("Model not found");

const agent = new Agent({
  initialState: {
    systemPrompt: "You are a helpful assistant.",
    model,
  },
  streamFn: models.streamSimple.bind(models),
});

agent.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    // Stream just the new text chunk
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await agent.prompt("Hello!");

Core Concepts

AgentMessage vs LLM Message

The agent works with AgentMessage, a flexible type that can include:

  • Standard LLM messages (user, assistant, toolResult)
  • Custom app-specific message types via declaration merging

LLMs only understand user, assistant, and toolResult. The convertToLlm function bridges this gap by filtering and transforming messages before each LLM call.

Message Flow

text
AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM
                    (optional)                           (required)
  1. transformContext: Prune old messages, inject external context
  2. convertToLlm: Filter out UI-only messages, convert custom types to LLM format

Event Flow

The agent emits events for UI updates. Understanding the event sequence helps build responsive interfaces.

prompt() Event Sequence

When you call prompt("Hello"):

text
prompt("Hello")
├─ agent_start
├─ turn_start
├─ message_start   { message: userMessage }      // Your prompt
├─ message_end     { message: userMessage }
├─ message_start   { message: assistantMessage } // LLM starts responding
├─ message_update  { message: partial... }       // Streaming chunks
├─ message_update  { message: partial... }
├─ message_end     { message: assistantMessage } // Complete response
├─ turn_end        { message, toolResults: [] }
└─ agent_end       { messages: [...] }

With Tool Calls

If the assistant calls tools, the loop continues:

text
prompt("Read config.json")
├─ agent_start
├─ turn_start
├─ message_start/end  { userMessage }
├─ message_start      { assistantMessage with toolCall }
├─ message_update...
├─ message_end        { assistantMessage }
├─ tool_execution_start  { toolCallId, toolName, args }
├─ tool_execution_update { partialResult }           // If tool streams
├─ tool_execution_end    { toolCallId, result }
├─ message_start/end  { toolResultMessage }
├─ turn_end           { message, toolResults: [toolResult] }
│
├─ turn_start                                        // Next turn
├─ message_start      { assistantMessage }           // LLM responds to tool result
├─ message_update...
├─ message_end
├─ turn_end
└─ agent_end

Tool execution mode is configurable:

  • parallel (default): preflight tool calls sequentially, execute allowed tools concurrently, emit tool_execution_end as soon as each tool is finalized, then emit toolResult messages and turn_end.toolResults in assistant source order
  • sequential: execute tool calls one by one, matching the historical behavior

In parallel mode, tool completion events follow tool completion order, but persisted toolResult messages still follow assistant source order.

The mode can be set globally via toolExecution in the agent config, or per-tool via executionMode on AgentTool. If any tool call in a batch targets a tool with executionMode: "sequential", the entire batch executes sequentially regardless of the global setting.

The beforeToolCall hook runs after tool_execution_start and validated argument parsing. It can block execution and attach terminate: true to the blocked result. The afterToolCall hook runs after tool execution finishes and before tool_execution_end and final tool result message events are emitted.

Tools, blocked beforeToolCall results, and afterToolCall overrides can return terminate: true to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets terminate: true. Mixed batches continue normally.

When you use the Agent class, assistant message_end processing is treated as a barrier before tool preflight begins. That means beforeToolCall sees agent state that already includes the assistant message that requested the tool call.

Request preparation and turn finalization

prepareRequest runs immediately before every conversational provider request, including the first. Use it to install canonical persisted context after pending input has been emitted:

typescript
agent.prepareRequest = async ({ context }) => ({
  context: { ...context, messages: await session.loadModelContext() },
});

prepareRequest does not poll queues. Steering queued while it runs waits for the next normal steering poll.

finishTurn runs after the assistant and all tool results are finalized, but before turn_end. It runs for normal, error, and aborted responses:

typescript
agent.finishTurn = async ({ message }) => {
  if (message.stopReason === "error" || message.stopReason === "aborted") return;
  if (shouldEndRun(message)) return { action: "end" };
  return needsAnotherResponse(message) ? { action: "continue" } : undefined;
};

Returning undefined preserves normal scheduling. { action: "end" } stops immediately after turn_end, before polling steering or follow-up queues or preparing another request. On a normal response, { action: "continue" } ensures one next provider request. If tool results, steering, or a follow-up already cause that request, they satisfy the decision and no additional request is made; otherwise the loop makes one context-only request. Error and aborted responses remain hard exits, so their decisions are ignored. finishTurn runs again after the next request, so returning { action: "continue" } unconditionally creates an endless loop.

To migrate from the removed shouldStopAfterTurn, return { action: "end" }. Guard error and aborted responses to preserve the old hook's normal-response-only invocation, especially when the predicate has side effects or assumes a successful response:

typescript
finishTurn: async (turn, signal) => {
  if (turn.message.stopReason === "error" || turn.message.stopReason === "aborted") return;
  return (await shouldStop(turn, signal)) ? { action: "end" } : undefined;
},

Each provider turn follows this lifecycle:

text
selected input events
→ prepareRequest
→ provider response
→ tool results
→ finishTurn
→ turn_end
→ existing continuation scheduling or agent_end

continue() and queued input

continue() retains its existing queue behavior. Empty and system-only transcripts reject without consuming queues. A non-assistant tail continues from existing context: steering is polled at startup, while follow-up input waits until the response naturally stops.

typescript
agent.followUp({ role: "user", content: "After the retry", timestamp: Date.now() });
await agent.continue(); // The first request retries the existing user/toolResult tail.

An assistant tail cannot be sent directly, so continue() falls back to one queued steering batch, then one queued follow-up batch. Queue mode still controls whether that selected batch contains one message or all messages:

typescript
agent.steer({ role: "user", content: "Continue from here", timestamp: Date.now() });
await agent.continue(); // Uses the queued message only because the tail is assistant.

Event Types

Event Description
agent_start Agent begins processing
agent_end Final event for the run. Awaited subscribers for this event still count toward settlement
turn_start New turn begins (one LLM call + tool executions)
turn_end Turn completes with assistant message and tool results
message_start Any message begins (user, assistant, toolResult)
message_update Assistant only. Includes assistantMessageEvent with delta
message_end Message completes
tool_execution_start Tool begins
tool_execution_update Tool streams progress
tool_execution_end Tool completes

Agent.subscribe() listeners are awaited in registration order. agent_end means no more loop events will be emitted, but await agent.waitForIdle() and await agent.prompt(...) only settle after awaited agent_end listeners finish.

Agent Options

text
/* Detailed source-code truncated for AI context efficiency. */

Agent State

typescript
interface AgentState {
  model: Model<any>;
  thinkingLevel: ThinkingLevel;
  tools: AgentTool<any>[];
  messages: AgentMessage[];
  readonly isStreaming: boolean;
  readonly streamingMessage?: AgentMessage;
  readonly pendingToolCalls: ReadonlySet<string>;
  readonly errorMessage?: string;
}

Access state via agent.state.

Assigning agent.state.tools = [...] or agent.state.messages = [...] copies the top-level array before storing it. Mutating the returned array mutates the current agent state.

The transcript owns the system prompt and tool declarations: the leading system message is the prompt, later system messages patch it (see SystemMessage in pi-ai). agent.state.systemPrompt is read-only and replays the transcript. agent.state.tools is the executable loadout; before every request the loop diffs it against the tools the transcript declares and, if they differ, announces the change in a system message (merged into a pending system message when one exists). pi-ai's getCurrentSystemMessage(messages) returns the replayed head, including declared tools, for any message array, including agent transcripts with custom message roles.

To change the prompt mid-conversation, append a system message with content (added instructions) or sections (named replacements):

typescript
await agent.prompt([
  { role: "system", content: "", sections: { skills: "<skills>...</skills>" }, timestamp: Date.now() },
  { role: "user", content: "Continue", timestamp: Date.now() },
]);

During streaming, agent.state.streamingMessage contains the current partial assistant message.

agent.state.isStreaming remains true until the run fully settles, including awaited agent_end subscribers.

Methods

Prompting

typescript
// Text prompt
await agent.prompt("Hello");

// With images
await agent.prompt("What's in this image?", [
  { type: "image", data: base64Data, mimeType: "image/jpeg" }
]);

// AgentMessage directly
await agent.prompt({ role: "user", content: "Hello", timestamp: Date.now() });

// Continue existing non-assistant input; an assistant tail may use queued input as fallback
await agent.continue();

State Management

typescript
agent.state.model = getModel("openai", "gpt-4o");
agent.state.thinkingLevel = "medium";
agent.state.tools = [myTool];
agent.toolExecution = "sequential";
agent.beforeToolCall = async ({ toolCall }) => undefined;
agent.afterToolCall = async ({ toolCall, result }) => undefined;
agent.prepareRequest = async ({ context }) => ({
  context: { ...context, messages: await loadCanonicalMessages() },
});
agent.finishTurn = async () => undefined;
agent.state.messages = newMessages; // top-level array is copied
agent.state.messages.push(message);
const nextQueuedMessages = agent.peekQueuedMessages(); // respects queue modes; does not consume
agent.reset();

Session and Thinking Budgets

typescript
agent.sessionId = "session-123";

agent.thinkingBudgets = {
  minimal: 128,
  low: 512,
  medium: 1024,
  high: 2048,
};

Control

typescript
agent.abort();           // Cancel current operation
await agent.waitForIdle(); // Wait for completion

Events

typescript
const unsubscribe = agent.subscribe(async (event, signal) => {
  if (event.type === "agent_end") {
    // Final barrier work for the run
    await flushSessionState(signal);
  }
});
unsubscribe();

Steering and Follow-up

Steering messages let you interrupt the agent while tools are running. Follow-up messages let you queue work after the agent would otherwise stop.

typescript
agent.steeringMode = "one-at-a-time";
agent.followUpMode = "one-at-a-time";

// While agent is running tools
agent.steer({
  role: "user",
  content: "Stop! Do this instead.",
  timestamp: Date.now(),
});

// After the agent finishes its current work
agent.followUp({
  role: "user",
  content: "Also summarize the result.",
  timestamp: Date.now(),
});

const steeringMode = agent.steeringMode;
const followUpMode = agent.followUpMode;

agent.clearSteeringQueue();
agent.clearFollowUpQueue();
agent.clearAllQueues();

Use clearSteeringQueue, clearFollowUpQueue, or clearAllQueues to drop queued messages.

When steering messages are detected after a turn completes:

  1. All tool calls from the current assistant message have already finished
  2. Steering messages are injected
  3. The LLM responds on the next turn

Follow-up messages are checked only when there are no more tool calls and no steering messages. If any are queued, they are injected and another turn runs.

Custom Message Types

Extend AgentMessage via declaration merging:

typescript
declare module "@earendil-works/pi-agent-core" {
  interface CustomAgentMessages {
    notification: { role: "notification"; text: string; timestamp: number };
  }
}

// Now valid
const msg: AgentMessage = { role: "notification", text: "Info", timestamp: Date.now() };

Handle custom types in convertToLlm:

typescript
const agent = new Agent({
  streamFn: models.streamSimple.bind(models),
  convertToLlm: (messages) => messages.flatMap(m => {
    if (m.role === "notification") return []; // Filter out
    return [m];
  }),
});

Tools

Define tools using AgentTool:

typescript
import { Type } from "typebox";

const readFileTool: AgentTool = {
  name: "read_file",
  label: "Read File",  // For UI display
  description: "Read a file's contents",
  parameters: Type.Object({
    path: Type.String({ description: "File path" }),
  }),
  // Override execution mode for this tool (optional).
  // "sequential" forces the entire batch to run one at a time.
  // "parallel" allows concurrent execution with other tool calls.
  // If omitted, the global toolExecution config applies.
  executionMode: "sequential",
  execute: async (toolCallId, params, signal, onUpdate) => {
    const content = await fs.readFile(params.path, "utf-8");

    // Optional: stream progress
    onUpdate?.({ content: [{ type: "text", text: "Reading..." }], details: {} });

    // Optional: add `terminate: true` here to skip the automatic follow-up LLM call
    // when every finalized tool result in the batch does the same.
    return {
      content: [{ type: "text", text: content }],
      details: { path: params.path, size: content.length },
    };
  },
};

agent.state.tools = [readFileTool];

Error Handling

Throw an error when a tool fails. Do not return error messages as content.

typescript
execute: async (toolCallId, params, signal, onUpdate) => {
  if (!fs.existsSync(params.path)) {
    throw new Error(`File not found: ${params.path}`);
  }
  // Return content only on success
  return { content: [{ type: "text", text: "..." }] };
}

Thrown errors are caught by the agent and reported to the LLM as tool errors with isError: true.

Return terminate: true from execute(), a blocked beforeToolCall, or afterToolCall to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted toolResult transcript messages remain standard LLM tool results.

MCP and Codemode

@earendil-works/pi-mcp connects to MCP servers and @earendil-works/pi-codemode runs model-written JavaScript that calls tools. examples/mcp-codemode wraps both as AgentTools: one tool per MCP tool, and a codemode tool whose scripts call the agent's tools through runToolCall(), so beforeToolCall and afterToolCall apply to those calls too.

Proxy Usage

For browser apps that proxy through a backend:

typescript
import { Agent, streamProxy } from "@earendil-works/pi-agent-core";

const agent = new Agent({
  streamFn: (model, context, options) =>
    streamProxy(model, context, {
      ...options,
      authToken: "...",
      proxyUrl: "https://your-server.com",
    }),
});

Low-Level API

For direct control without the Agent class:

typescript
import { agentLoop, agentLoopContinue } from "@earendil-works/pi-agent-core";

const context: AgentContext = {
  messages: [{ role: "system", content: "You are helpful.", timestamp: Date.now() }],
  tools: [],
};

const config: AgentLoopConfig = {
  model: getModel("openai", "gpt-4o"),
  convertToLlm: (msgs) => msgs.filter(m => ["user", "assistant", "toolResult"].includes(m.role)),
  toolExecution: "parallel",  // overridden by per-tool executionMode if set
  beforeToolCall: async ({ toolCall, args, context }) => undefined,
  afterToolCall: async ({ toolCall, result, isError, context }) => undefined,
};

const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };

const streamFn = models.streamSimple.bind(models);
for await (const event of agentLoop([userMessage], context, config, undefined, streamFn)) {
  console.log(event.type);
}

// Continue from existing context
for await (const event of agentLoopContinue(context, config, undefined, streamFn)) {
  console.log(event.type);
}

These low-level streams are observational. They preserve event order, but they do not wait for your async event handling to settle before later producer phases continue. If you need message processing to act as a barrier before tool preflight, use the Agent class instead of raw agentLoop() or agentLoopContinue().

License

MIT


Overview (`packages/ai/README.md`)

@earendil-works/pi-ai

Unified LLM API with provider collections, automatic auth resolution, token and cost tracking, and simple context persistence and hand-off to other models mid-session.

Note: The chat catalog only includes models that support tool calling (function calling), as this is essential for agentic workflows. Image and classifier catalogs use their operation-specific capabilities.

Table of Contents

Supported Providers

  • OpenAI (including the Decisions classifier API)
  • Ant Ling
  • Azure OpenAI (Responses)
  • OpenAI Codex (legacy) (ChatGPT Plus/Pro subscription, requires OAuth, see below)
  • Radius (API key or OAuth, with a dynamically refreshed gateway catalog)
  • TypeSafe (System One classifier API)
  • DeepSeek
  • NVIDIA NIM
  • Anthropic
  • Google
  • Vertex AI (Gemini via Vertex AI)
  • Mistral
  • Groq
  • Cerebras
  • Cloudflare AI Gateway
  • Cloudflare Workers AI
  • xAI
  • OpenRouter
  • Vercel AI Gateway
  • ZAI Coding Plan (Global) (with separate China provider)
  • MiniMax (with separate China provider)
  • Together AI
  • Baseten
  • Hugging Face
  • Moonshot AI (with separate China provider)
  • GitHub Copilot (requires OAuth, see below)
  • Amazon Bedrock
  • OpenCode Zen
  • OpenCode Go
  • Fireworks (uses OpenAI- and Anthropic-compatible APIs)
  • Kimi For Coding (Moonshot AI subscription endpoint, uses Anthropic-compatible API)
  • Meta (Model API, uses OpenAI Responses-compatible API)
  • Qwen Token Plan (separate Individual and existing catalogs, with a separate China provider)
  • Xiaomi MiMo (defaults to API billing endpoint, with separate Token Plan providers for cn/ams/sgp regions)
  • Any OpenAI-compatible API: Ollama, vLLM, LM Studio, etc.

Installation

bash
npm install @earendil-works/pi-ai

TypeBox exports are re-exported from @earendil-works/pi-ai: Type, Static, and TSchema.

Quick Start

You build a Models collection of providers and stream through it. The quickest start registers every built-in provider; apps that care about bundle size register individual providers instead (see Provider Factories and Bundling and Tree Shaking).

text
/* Detailed source-code truncated for AI context efficiency. */

usage.cost.totalFromProvider is true when usage.cost.total is the billed cost reported by the provider and false when it is estimated from the model's catalog rates. A missing value, as in sessions recorded before this field existed, also means false.

Snippets in the rest of this README assume a models collection set up like this (with the relevant providers registered).

usage.cost.total is the best available total cost. For OpenRouter chat responses, it uses the cost reported by OpenRouter, including both the OpenRouter charge and upstream inference cost for BYOK requests. When provider accounting is unavailable or invalid, it falls back to the model's catalog rates. The input, output, cacheRead, and cacheWrite cost components remain catalog estimates and may not sum to a provider-reported total.

Providers and Models

A provider is the runtime unit: it owns its model catalog, its auth (API key resolution, OAuth flows), and its stream behavior. A Models collection holds providers and routes every request to the provider that owns the model.

Providers internally share API implementations (the wire protocols): Anthropic models use anthropic-messages, OpenAI uses openai-responses, while xAI, Groq, Cerebras, OpenRouter, and most others share openai-completions. Mixed-API providers (GitHub Copilot, OpenCode Zen) dispatch per model.

Provider Factories

For apps that only need specific providers, there is one factory per built-in provider, each a subpath import that pulls only that provider's catalog:

typescript
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { openrouterProvider } from '@earendil-works/pi-ai/providers/openrouter';
import { amazonBedrockProvider } from '@earendil-works/pi-ai/providers/amazon-bedrock';
// ...one module per provider in the Supported Providers list

const models = createModels();
models.setProvider(anthropicProvider());
models.setProvider(openrouterProvider());

Provider factories import their model catalog and a lazy API wrapper. They do not import other providers. With bundler code splitting, SDK implementations (@anthropic-ai/sdk, openai, @google/genai, etc.) stay in lazy chunks loaded on the first request to a model of that API.

All Built-in Providers

For apps that want everything (as in Quick Start):

typescript
import { builtinModels } from '@earendil-works/pi-ai/providers/all';

const models = builtinModels(); // a Models collection with every built-in provider registered

This imports all catalogs and every built-in provider factory. It is the heavy, explicit entrypoint. builtinModels() accepts the same options as createModels() (credentials, authContext); builtinProviders() returns the provider array if you want to register them on your own collection.

Querying Models

Reads are synchronous and return the last-known lists:

typescript
const providers = models.getProviders();           // registered Provider objects
const provider = models.getProvider('anthropic');  // one provider

const all = models.getModels();                    // every chat model across providers
const anthropicModels = models.getModels('anthropic');
const model = models.getModel('anthropic', 'claude-sonnet-4-5');

for (const m of anthropicModels) {
  console.log(`${m.id}: ${m.name}`);
  console.log(`  API: ${m.api}`);
  console.log(`  Context: ${m.contextWindow} tokens`);
  console.log(`  Vision: ${m.input.includes('image')}`);
  console.log(`  Reasoning: ${m.reasoning}`);
}

The unqualified reads getModels()/getModel()/getAvailable() return chat models (Model<Api>) usable with stream(). The *OfType reads return one model type, and getAllModels()/getAllAvailable() return every type as AnyModel:

typescript
const images = models.getModelsOfType('image', 'openrouter');           // ImageModel[]
const flux = models.getModelOfType('image', 'openrouter', 'black-forest-labs/flux.2-pro');
const jev = models.getModelOfType('classifier', 'typesafe', 'jev-latest');
const availableImages = await models.getAvailableOfType('image');
const everything = models.getAllModels();                               // AnyModel[]

The model's type decides which operation accepts it: chat models stream, type: "image" models generate images, and type: "classifier" models classify structured state. type is optional on chat models, so a model without type is a chat model. Do not compare type directly; narrow mixed lists with isModelType() or read the effective type with getModelType():

typescript
import { isModelType } from '@earendil-works/pi-ai';

for (const model of models.getAllModels()) {
  if (isModelType(model, 'image')) {
    // model: ImageModel<ImageApi>
  }
}

IDs are unique within each provider and type; one upstream model may have separate entries for different operations. On a provider, getModels() returns chat models and the optional getAllModels() returns every type; providers with only chat models can omit it.

Dynamically listed chat models are typed Model<Api>. Narrow with the hasApi() guard when you need API-specific option typing:

typescript
import { hasApi } from '@earendil-works/pi-ai';

const m = models.getModel('anthropic', 'claude-sonnet-4-5');
if (m && hasApi(m, 'anthropic-messages')) {
  // m: Model<'anthropic-messages'> — stream options fully typed
  models.stream(m, context, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
}

Static Catalog Reads

For tooling that wants the generated built-in catalog with full literal typing (provider and model IDs auto-complete), independent of any collection:

typescript
import {
  getAllBuiltinModels,
  getBuiltinClassifierModel,
  getBuiltinClassifierModels,
  getBuiltinImageModel,
  getBuiltinImageModels,
  getBuiltinModel,
  getBuiltinModels,
  getBuiltinProviders,
} from '@earendil-works/pi-ai/providers/all';

const model = getBuiltinModel('openai', 'gpt-4o-mini'); // typed Model<'openai-responses'>
const radius = getBuiltinModel('radius', 'balanced');   // typed Model<'pi-messages'>
const flux = getBuiltinImageModel('openrouter', 'black-forest-labs/flux.2-pro');
const jev = getBuiltinClassifierModel('typesafe', 'jev-latest');
const providers = getBuiltinProviders();
const openrouterChat = getBuiltinModels('openrouter');        // Model[]
const openrouterImages = getBuiltinImageModels('openrouter'); // ImageModel[]
const typesafeClassifiers = getBuiltinClassifierModels('typesafe'); // ClassifierModel[]
const openrouterAll = getAllBuiltinModels('openrouter');      // AnyModel[]

Dynamic Providers

Providers may have dynamic model lists (a llama.cpp server, a live OpenRouter listing). Reads stay sync; fetching is an explicit async verb:

typescript
// getModels() returns the last-known list (empty before the first refresh)
await models.refresh({ providers: ['llamacpp'] }); // refresh one provider
await models.refresh();                            // refresh all providers concurrently, best-effort
const fresh = models.getModel('llamacpp', 'qwen3-30b');

Static built-in providers are no-ops for refresh(). Radius is both static and dynamic: it ships the public radius.pi.dev catalog for synchronous API lookup, then overlays cached and freshly fetched /v1/config models when refreshed with configured auth. See createProvider() for building a dynamic provider.

Auth

Every provider owns its auth: how API keys resolve (stored credentials, environment variables, ambient sources like AWS profiles or gcloud ADC) and, where supported, OAuth login/refresh flows.

How Auth Resolves

When you call models.stream(), the collection resolves auth through the owning provider and merges it into the request. Explicit per-request values always win:

typescript
// Resolved through the provider (env var, stored credential, OAuth token):
await models.complete(model, context);

// Explicit key wins over anything the provider would resolve:
await models.complete(model, context, { apiKey: 'sk-explicit' });

You can inspect resolution without making a request. Pass a provider ID for provider-scoped auth, or a model to include its static model.headers:

typescript
const providerAuth = await models.getAuth(model.provider);
const modelAuth = await models.getAuth(model);

if (modelAuth) {
  console.log(`configured via ${modelAuth.source}`); // e.g. "ANTHROPIC_API_KEY", "OAuth", "stored credential"
  console.log(modelAuth.auth.headers);              // Provider auth headers + model.headers
} else {
  console.log('not configured');
}

Both overloads resolve credentials, refresh expired OAuth when necessary, and may return an auth-derived apiKey, headers, or baseUrl. getAuth() resolves undefined for unconfigured providers and rejects with ModelsError when something is actually broken ("oauth": token refresh failed, credential preserved for re-login; "auth": key resolution or credential store failure). Request paths surface the same failures as stream errors.

getAuth(), checkAuth(), getAvailable(), login, and logout accept optional caller cancellation through their existing options or interaction objects and remain unbounded when no signal is supplied. Provider login, ApiKeyAuth.check, ApiKeyAuth.resolve, and OAuthAuth.refresh implementations always receive a concrete signal and must honor it for blocking work.

Transforming Request Headers

Models.stream(), complete(), streamSimple(), and completeSimple() accept a Models-only transformHeaders option. It runs once after provider auth, model.headers, and explicit options.headers have been merged, but before provider dispatch:

typescript
const response = await models.completeSimple(model, context, {
  headers: { "X-Client": "my-app" },
  transformHeaders: async (headers) => ({
    ...headers,
    "X-Request-ID": crypto.randomUUID(),
  }),
});

The ordering is:

text
provider auth headers -> model.headers -> explicit options.headers -> transformHeaders -> Provider.stream*()

Header names are merged case-insensitively. Explicit headers override auth/model headers, and the transform has final control; returning null for a header suppresses lower-level defaults that support deletion.

transformHeaders belongs to Models, not Provider. A Models implementation must consume it and remove it before calling Provider.stream*(). Provider implementations continue receiving ordinary ApiStreamOptions or SimpleStreamOptions and never handle the transform themselves. Use this option instead of calling getAuth(model) before stream*(), which would resolve request auth twice.

Client Identity

identity controls how requests identify the calling app to providers. Set it on createModels() as a default, or per request; a request's fields override the Models default field by field:

typescript
const models = createModels({
  identity: {
    userAgent: 'detailed',
    version: '2.1.0',
    attribution: { name: 'my-app', url: 'https://my-app.example', categories: ['cloud-agent'] },
  },
});

await models.completeSimple(model, context, { identity: { attribution: null } });
  • userAgent: "detailed" (default) sends pi/<version> (; <runtime>; <arch>), "short" sends pi, and { custom: "..." } sends the given value. On Bedrock the value is appended to the AWS SDK user agent.

  • version: the version in the detailed User-Agent. Default: the pi-ai version.

  • attribution: provider attribution headers. Absent (default) or null sends none; null also turns off attribution from a merged identity. { name, url, categories? } maps to each provider's headers:

    • OpenRouter: HTTP-Referer: (the app's identity in OpenRouter's rankings, grouped by origin), X-OpenRouter-Title: <name>, and X-OpenRouter-Categories (comma-separated).
    • NVIDIA NIM: X-BILLING-INVOKE-ORIGIN: <name> with its first letter upper-cased.
    • OpenCode: x-opencode-client: <name>.

    PI_ATTRIBUTION is pi's: { name: "pi", url: "https://pi.dev", categories: ["cli-agent"] }.

Identity headers are defaults below provider, model, and request headers, so configured headers win and a null request header removes one. Providers that send a fixed client identity, such as GitHub Copilot and Anthropic OAuth, keep it. withClientIdentity(models, identity) returns a wrapper of a Models (or MutableModels) whose requests use identity over the one the Models was created with, for libraries that receive a Models from their host. identity is plain JSON, so it can be persisted with other request options.

Credential Store

Stored credentials (API keys entered interactively, OAuth tokens) live in a CredentialStore — one type-tagged credential per provider. pi-ai ships an in-memory default; apps inject persistent storage:

typescript
import { createModels, type CredentialStore } from '@earendil-works/pi-ai';

const models = createModels({ credentials: myFileBackedStore });
// builtinModels() takes the same options:
// const models = builtinModels({ credentials: myFileBackedStore });

The contract is small: read(providerId), list() for non-secret { providerId, type } metadata, modify(providerId, fn) (the only write path — a serialized read-modify-write), and delete(providerId). Each operation accepts optional cancellation options. Enumeration must not resolve secrets or execute configured key commands. OAuth token refresh runs inside modify, so concurrent requests and processes cannot double-refresh a rotated token. A stored credential owns its provider: environment variables are only consulted when nothing is stored, and a failed refresh never silently falls back to an env key.

API-key credentials use the same discriminator as pi's auth.json and can carry provider-scoped env/config values:

typescript
const credential = {
  type: 'api_key',
  key: '...',
  env: {
    CLOUDFLARE_ACCOUNT_ID: 'account-id',
    CLOUDFLARE_GATEWAY_ID: 'gateway-id'
  }
} as const;

Environment Variables

Built-in providers resolve these env vars (Node.js; in browsers pass apiKey explicitly):

Provider Environment Variable(s)
OpenAI OPENAI_API_KEY
Ant Ling ANT_LING_API_KEY
Azure OpenAI AZURE_OPENAI_API_KEY + AZURE_OPENAI_BASE_URL (e.g. https://{resource}.ai.azure.com) or AZURE_OPENAI_RESOURCE_NAME. Supports *.openai.azure.com, *.cognitiveservices.azure.com and *.ai.azure.com; root endpoints auto-normalize to /openai/v1. Optional: AZURE_OPENAI_API_VERSION (default v1), AZURE_OPENAI_DEPLOYMENT_NAME_MAP.
Anthropic ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN
Radius RADIUS_API_KEY
TypeSafe TYPESAFE_API_KEY
DeepSeek DEEPSEEK_API_KEY
NVIDIA NIM NVIDIA_API_KEY
Google GEMINI_API_KEY
Vertex AI GOOGLE_CLOUD_API_KEY or GOOGLE_CLOUD_PROJECT (or GCLOUD_PROJECT) + GOOGLE_CLOUD_LOCATION + ADC
Mistral MISTRAL_API_KEY
Groq GROQ_API_KEY
Cerebras CEREBRAS_API_KEY
Cloudflare AI Gateway CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_GATEWAY_ID
Cloudflare Workers AI CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID
xAI XAI_API_KEY
Fireworks FIREWORKS_API_KEY
Together AI TOGETHER_API_KEY
Baseten BASETEN_API_KEY
OpenRouter OPENROUTER_API_KEY
Vercel AI Gateway AI_GATEWAY_API_KEY
ZAI Coding Plan (Global) ZAI_API_KEY
ZAI Coding Plan (China) ZAI_CODING_CN_API_KEY
MiniMax (Global) MINIMAX_API_KEY
MiniMax (China) MINIMAX_CN_API_KEY
Moonshot AI / Moonshot AI (China) MOONSHOT_API_KEY
Hugging Face HF_TOKEN
OpenCode Zen / OpenCode Go OPENCODE_API_KEY
Kimi For Coding KIMI_API_KEY
Meta META_API_KEY
Qwen Token Plan (existing catalog) QWEN_TOKEN_PLAN_API_KEY
Qwen Token Plan (Individual) QWEN_TOKEN_PLAN_API_KEY
Qwen Token Plan (China) QWEN_TOKEN_PLAN_CN_API_KEY
Xiaomi MiMo (API billing) XIAOMI_API_KEY
Xiaomi MiMo Token Plan (China) XIAOMI_TOKEN_PLAN_CN_API_KEY
Xiaomi MiMo Token Plan (Amsterdam) XIAOMI_TOKEN_PLAN_AMS_API_KEY
Xiaomi MiMo Token Plan (Singapore) XIAOMI_TOKEN_PLAN_SGP_API_KEY
GitHub Copilot COPILOT_GITHUB_TOKEN

qwen-token-plan-individual and qwen-token-plan share the international endpoint and
QWEN_TOKEN_PLAN_API_KEY. The Individual provider exposes only the models documented for Individual
subscriptions, while the existing provider retains its broader catalog for backward compatibility.
Stored credentials remain provider-scoped, so save the key under the provider ID you register.

Amazon Bedrock resolves ambient AWS credentials (AWS_PROFILE, access key pairs, AWS_BEARER_TOKEN_BEDROCK, ECS task roles, web identity tokens); its provider-owned login flow supports bearer tokens, AWS profiles, and the existing credential chain. Vertex AI resolves either an explicit key or gcloud Application Default Credentials plus project/location, with a provider-owned login flow for API keys, ADC, and service-account files.

Tools

Tools enable LLMs to interact with external systems. This library uses TypeBox schemas for type-safe tool definitions with automatic validation using TypeBox's built-in validator and value conversion utilities. TypeBox schemas can be serialized and deserialized as plain JSON, making them ideal for distributed systems.

Defining Tools

typescript
import { Type, type Tool, StringEnum } from '@earendil-works/pi-ai';

// Define tool parameters with TypeBox
const weatherTool: Tool = {
  name: 'get_weather',
  description: 'Get current weather for a location',
  parameters: Type.Object({
    location: Type.String({ description: 'City name or coordinates' }),
    units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
  })
};

// Note: For Google API compatibility, use StringEnum helper instead of Type.Enum
// Type.Enum generates anyOf/const patterns that Google doesn't support

const bookMeetingTool: Tool = {
  name: 'book_meeting',
  description: 'Schedule a meeting',
  parameters: Type.Object({
    title: Type.String({ minLength: 1 }),
    startTime: Type.String({ format: 'date-time' }),
    endTime: Type.String({ format: 'date-time' }),
    attendees: Type.Array(Type.String({ format: 'email' }), { minItems: 1 })
  })
};

Constrained Sampling for Tools

Tools can opt in to provider-side constrained sampling. For JSON-schema tools, strict: 'prefer' uses provider-side strict schema enforcement when supported and otherwise falls back to normal tool calling. strict: 'require' fails the request when the active provider/model cannot honor it. Set constrainedSampling: false to explicitly opt out; it behaves the same as omitting the field.

typescript
const strictTool: Tool = {
  name: 'edit_file',
  description: 'Edit a file',
  parameters: Type.Object({
    path: Type.String(),
    content: Type.String()
  }, { additionalProperties: false }),
  constrainedSampling: { type: 'json_schema', strict: 'prefer' }
};

Strict JSON-schema constrained sampling is supported for OpenAI, Anthropic, supported Amazon Bedrock Converse models, Mistral, and Gemini 3 tool calls through the Google Generative AI and Vertex adapters. Google uses VALIDATED function-calling mode (or ANY when explicitly requested); earlier Gemini versions fall back for strict: 'prefer' and reject strict: 'require' because they do not enforce required parameters. Bedrock strict-tool capability is generated from model structured-output metadata; custom Bedrock models can override compat.supportsStrictMode. OpenAI Responses and Chat Completions can also emit grammar-constrained custom tools with OpenAI Lark or regex grammar variants. If multiple OpenAI variants are supplied, Lark is preferred over regex. Grammar constraints are enforced when the active model supports grammar tools; otherwise the tool falls back to normal function/JSON-schema handling. Grammar tool capability is model metadata: the generated catalog sets compat.supportsOpenAIGrammarTools for GPT-5+ models on endpoints that pass OpenAI custom tools through (OpenAI, OpenAI Codex, Azure OpenAI Responses, GitHub Copilot, opencode, and Cloudflare AI Gateway). OpenAI rejects type: "custom" tools for pre-GPT-5 models, and gateways that normalize tool schemas (e.g. OpenRouter) mangle them, so the flag stays off elsewhere. Custom model definitions can opt in via compat. Grammar-capable models reject grammar configurations without a non-empty supported variant. Native grammar tools must have an object parameter schema with exactly one required string property:

typescript
const patchTool: Tool = {
  name: 'apply_patch',
  description: 'Apply a patch',
  parameters: Type.Object({
    input: Type.String()
  }, { additionalProperties: false }),
  constrainedSampling: {
    type: 'grammar',
    variants: {
      openai_lark: 'start: /.+/s'
    }
  }
};

Handling Tool Calls

Tool results use content blocks and can include both text and images:

typescript
import { readFileSync } from 'fs';

const context: Context = {
  messages: [{ role: 'user', content: 'What is the weather in London?', timestamp: Date.now() }],
  tools: [weatherTool]
};

const response = await models.complete(model, context);

// Check for tool calls in the response
for (const block of response.content) {
  if (block.type === 'toolCall') {
    // Execute your tool with the arguments
    // See "Validating Tool Arguments" section for validation
    const result = await executeWeatherApi(block.arguments);

    // Add tool result with text content
    context.messages.push({
      role: 'toolResult',
      toolCallId: block.id,
      toolName: block.name,
      content: [{ type: 'text', text: JSON.stringify(result) }],
      isError: false,
      timestamp: Date.now()
    });
  }
}

// Tool results can also include images (for vision-capable models)
const imageBuffer = readFileSync('chart.png');
context.messages.push({
  role: 'toolResult',
  toolCallId: 'tool_xyz',
  toolName: 'generate_chart',
  content: [
    { type: 'text', text: 'Generated chart showing temperature trends' },
    { type: 'image', data: imageBuffer.toString('base64'), mimeType: 'image/png' }
  ],
  isError: false,
  timestamp: Date.now()
});

Streaming Tool Calls with Partial JSON

During streaming, tool call arguments are progressively parsed as they arrive. This enables real-time UI updates before the complete arguments are available:

typescript
const s = models.stream(model, context);

for await (const event of s) {
  if (event.type === 'toolcall_delta') {
    const toolCall = event.partial.content[event.contentIndex];

    // toolCall.arguments contains partially parsed JSON during streaming
    // This allows for progressive UI updates
    if (toolCall.type === 'toolCall' && toolCall.arguments) {
      // BE DEFENSIVE: arguments may be incomplete
      // Example: Show file path being written even before content is complete
      if (toolCall.name === 'write_file' && toolCall.arguments.path) {
        console.log(`Writing to: ${toolCall.arguments.path}`);

        // Content might be partial or missing
        if (toolCall.arguments.content) {
          console.log(`Content preview: ${toolCall.arguments.content.substring(0, 100)}...`);
        }
      }
    }
  }

  if (event.type === 'toolcall_end') {
    // Here toolCall.arguments is complete (but not yet validated)
    const toolCall = event.toolCall;
    console.log(`Tool completed: ${toolCall.name}`, toolCall.arguments);
  }
}

Important notes about partial tool arguments:

  • During toolcall_delta events, arguments contains the best-effort parse of partial JSON
  • Fields may be missing or incomplete - always check for existence before use
  • String values may be truncated mid-word
  • Arrays may be incomplete
  • Nested objects may be partially populated
  • At minimum, arguments will be an empty object {}, never undefined
  • The Google provider does not support function call streaming. Instead, you will receive a single toolcall_delta event with the full arguments.

Validating Tool Arguments

When implementing your own tool execution loop, use validateToolCall to validate arguments before passing them to your tools:

typescript
import { validateToolCall, type Tool } from '@earendil-works/pi-ai';

const tools: Tool[] = [weatherTool, calculatorTool];
const s = models.stream(model, { messages, tools });

for await (const event of s) {
  if (event.type === 'toolcall_end') {
    const toolCall = event.toolCall;

    try {
      // Validate arguments against the tool's schema (throws on invalid args)
      const validatedArgs = validateToolCall(tools, toolCall);
      const result = await executeMyTool(toolCall.name, validatedArgs);
      // ... add tool result to context
    } catch (error) {
      // Validation failed - return error as tool result so model can retry
      context.messages.push({
        role: 'toolResult',
        toolCallId: toolCall.id,
        toolName: toolCall.name,
        content: [{ type: 'text', text: error.message }],
        isError: true,
        timestamp: Date.now()
      });
    }
  }
}

Complete Event Reference

Successful generation follows start → updates* → done. A failure after generation starts follows start → updates* → error. Request setup may fail before generation starts, in which case the stream contains only error; done and update events are invalid before start. Direct API streamSimple() calls throw synchronously when request auth is missing.

Every non-terminal event's partial is the shared live response-so-far helper. It is intentionally not an event-time snapshot: providers may mutate the same message and content blocks as generation advances, including while older events wait in the stream queue. Inspect it when handling an event instead of retaining it as historical state. Text and ordinary thinking blocks are empty when their *_start event is emitted and grow only through matching *_delta events until the authoritative *_end; redacted thinking may be complete at start and emit no deltas. Tool-call arguments at toolcall_start are provider-specific; toolcall_delta carries subsequent JSON updates.

All streaming events emitted during assistant message generation:

Event Type Description Key Properties
start Stream begins partial: Initial assistant message structure
text_start Text block starts contentIndex: Position in content array
text_delta Text chunk received delta: New text, contentIndex: Position
text_end Text block complete content: Full text, contentIndex: Position
thinking_start Thinking block starts contentIndex: Position in content array
thinking_delta Thinking chunk received delta: New text, contentIndex: Position
thinking_end Thinking block complete content: Full thinking, contentIndex: Position
toolcall_start Tool call begins contentIndex: Position in content array
toolcall_delta Tool arguments streaming delta: JSON chunk, partial.content[contentIndex].arguments: Partial parsed args
toolcall_end Tool call complete toolCall: Complete, but not schema-validated, tool call with id, name, arguments
done Stream complete reason: Stop reason ("stop", "length", "toolUse"), message: Final assistant message
error Error occurred reason: Error type ("error" or "aborted"), error: AssistantMessage with partial content

Streaming events for different content blocks are not guaranteed to be contiguous. Providers may emit deltas for text, thinking, and tool calls in the same upstream chunk, and pi may surface corresponding events interleaved, for example text_start, text_delta, toolcall_start, text_delta, toolcall_delta. Consumers must use contentIndex to associate each delta/end event with its block and must not assume that a block's *_start/*_delta/*_end sequence is uninterrupted by events for other blocks.

Image Input

Models with vision capabilities can process images. You can check if a model supports images via the input property. If you pass images to a non-vision model, they are silently ignored.

typescript
import { readFileSync } from 'fs';

const model = models.getModel('openai', 'gpt-4o-mini')!;

// Check if model supports images
if (model.input.includes('image')) {
  console.log('Model supports vision');
}

const imageBuffer = readFileSync('image.png');
const base64Image = imageBuffer.toString('base64');

const response = await models.complete(model, {
  messages: [{
    role: 'user',
    content: [
      { type: 'text', text: 'What is in this image?' },
      { type: 'image', data: base64Image, mimeType: 'image/png' }
    ],
    timestamp: Date.now()
  }]
});

// Access the response
for (const block of response.content) {
  if (block.type === 'text') {
    console.log(block.text);
  }
}

Image Generation

Image models live in the same Models collection and on the same Provider as chat models, so one credential per provider covers both. They are typed ImageModel with type: "image" and are used through generateImages(), a one-shot API that waits for the provider response and returns the final AssistantImages result. Do not use the chat/stream APIs for them; stream() rejects image models.

Basic Image Generation

typescript
import { builtinModels } from '@earendil-works/pi-ai/providers/all';

const models = builtinModels();

const model = models.getModelOfType('image', 'openrouter', 'google/gemini-2.5-flash-image')!;

// Auth resolves through the provider (OPENROUTER_API_KEY here); explicit apiKey wins
const result = await models.generateImages(model, {
  input: [{ type: 'text', text: 'Generate a red circle on a plain white background.' }]
});

for (const block of result.output) {
  if (block.type === 'text') {
    console.log(block.text);
  } else if (block.type === 'image') {
    console.log(block.mimeType);
    console.log(block.data.substring(0, 32));
  }
}

generateImages() accepts only ImageModel values. If an upstream model supports both chat and image generation, the catalog contains separate entries with the same provider and ID: getModel() returns its chat operation and getModelOfType('image', ...) returns its image operation. Failures never reject; they return an AssistantImages with stopReason: "error", including unknown providers, unconfigured auth, and providers without an image implementation.

A provider declares image support with the images option of createProvider(): a map from model.api to an implementation with generateImages(). Image models go into the same models list as chat models. api becomes optional when images is present, so an image-only provider is just a provider without chat models:

typescript
import { createProvider, envApiKeyAuth } from '@earendil-works/pi-ai';

const pixels = createProvider({
  id: 'pixels',
  auth: { apiKey: envApiKeyAuth('Pixels API key', ['PIXELS_API_KEY']) },
  models: [{
    type: 'image',
    id: 'flux-pro',
    name: 'FLUX Pro',
    api: 'pixels-images',
    provider: 'pixels',
    baseUrl: 'https://api.pixels.test/v1',
    input: ['text'],
    output: ['image'],
    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  }],
  images: {
    'pixels-images': { generateImages: async (model, context, options) => { /* ... */ } },
  },
});
models.setProvider(pixels);

The old global API (getImageModel() / getImageModels() / getImageProviders() / generateImages()) remains available on the compat entrypoint:

typescript
import { getImageModel, generateImages } from '@earendil-works/pi-ai/compat';

const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image');
const result = await generateImages(model, {
  input: [{ type: 'text', text: 'Generate a red circle on a plain white background.' }]
}, {
  apiKey: process.env.OPENROUTER_API_KEY
});

Some models also support image input:

typescript
import { readFileSync } from 'fs';

const imageBuffer = readFileSync('input.png');
const result = await models.generateImages(model, {
  input: [
    { type: 'text', text: 'Create a variation of this image with a blue background.' },
    { type: 'image', data: imageBuffer.toString('base64'), mimeType: 'image/png' }
  ]
});

Check capabilities on the model metadata:

typescript
console.log(model.input);  // ['text'] or ['text', 'image']
console.log(model.output); // ['image'] or ['image', 'text']

Notes and Limitations

  • Image models and chat models share Models and Provider; list them with getModelsOfType('image') and run them with generateImages(), never the chat/stream APIs.
  • Image-generation models do not participate in tool calling.
  • Outputs are returned in AssistantImages.output and can include both base64-encoded ImageContent blocks and TextContent blocks.
  • Some models return only images, others return images plus text. Check model.output.
  • Some models accept image input, others are text-to-image only. Check model.input.
  • Like the streaming APIs, image generation supports options such as apiKey, signal, headers, onPayload, and onResponse, and results may include stopReason, responseId, and usage.
  • If you want a model to analyze images in a conversation or call tools, use the regular chat APIs with a model that supports image input.
  • At the moment, image generation is available through only one provider, OpenRouter.

Classification

Classifier models consume structured JSON state, and optionally images, and answer one or more typed questions. They do not use chat or image-generation APIs. TypeSafe's Jev model, Cloudflare's Clef models, and OpenAI's GPT-6 Luna are available from these built-in providers:

Provider Model IDs Auth
typesafe jev-latest TYPESAFE_API_KEY
openrouter typesafe/jev-1.13, typesafe/jev-1.14, ~typesafe/jev-latest, cloudflare/clef, cloudflare/clef-flash, cloudflare/clef-omni OPENROUTER_API_KEY or OpenRouter OAuth
cloudflare-workers-ai typesafe/jev, typesafe/jev-1.14.0, @cf/cloudflare/clef, @cf/cloudflare/clef-flash, @cf/cloudflare/clef-omni CLOUDFLARE_API_KEY and CLOUDFLARE_ACCOUNT_ID
vercel-ai-gateway typesafe-ai/jev AI_GATEWAY_API_KEY
opencode jev-1.13, jev-1.13-free OPENCODE_API_KEY
openai gpt-6-luna OPENAI_API_KEY
typescript
import { builtinModels } from '@earendil-works/pi-ai/providers/all';

const models = builtinModels();
const model = models.getModelOfType('classifier', 'typesafe', 'jev-latest')!;
const result = await models.classify(model, {
  state: { message: 'The change works perfectly, thanks.' },
  questions: {
    category: {
      type: 'choice',
      instructions: 'Classify the message.',
      criteria: {
        approval: 'The user approves of the result',
        correction: 'The user requests a correction'
      }
    },
    satisfaction: {
      type: 'score',
      instructions: 'Score user satisfaction.',
      criteria: ['dissatisfied', 'neutral', 'satisfied']
    },
    approved: {
      type: 'bool',
      instructions: 'Does the user approve?',
      criteria: { true: 'Approval', false: 'No approval' }
    }
  }
});

console.log(result.answers);

The public contract uses bool questions and { type: "bool", probability } answers. The TypeSafe adapter translates those to and from its noul wire representation. Like image generation, classify() resolves to a result with stopReason: "error" instead of rejecting for provider, authentication, or response errors.

When the service reports token counts, result.usage carries them with their cost at the model's catalog price, the same Usage shape as chat messages. All System One services report token counts; a request that was answered with malformed answers keeps its usage. Local classifiers such as llama-cpp-classify report no usage.

ClassifierOptions.temperature divides the answer logits by the given value before they are normalized; values above 1 soften the distribution. APIs that cannot apply it, such as System One and OpenAI Decisions, ignore it.

ClassifierContext.images holds image blocks ({ type: "image", data, mimeType }) judged together with the state. Only models whose input includes "image" accept them; classify() returns an error result for other models.

System One images

System One services take images in three forms. The model's compat.imageInput chooses the form ("assets", "images", or "state-image-urls"), and compat.sendImageOptInHeader controls TypeSafe's opt-in header:

  • TypeSafe (typesafe, and Jev on Workers AI) takes PNG and JPEG images as assets, { "": { type: "image", format: "png", data_base64 } }. Every asset must be referenced from the request, so the state is sent as ["", ..., state]. Images are experimental on TypeSafe's own API and need the TypeSafe-Danger-Zone-Images: true header, which the API sends with requests that carry images.
  • Clef on Workers AI takes up to four PNG, JPEG, or WebP images as an images list of data URLs, next to the unchanged state.
  • OpenRouter takes images as { type: "image_url", image_url: { url } } parts before the state: [part, ..., state]. It answers requests with assets but drops the images, without an error.

Built-in catalog entries set compat explicitly, so they keep their form when baseUrl points at a proxy. For models without it, the APIs detect the form: OpenRouter by provider openrouter or a baseUrl containing openrouter.ai, Clef by a model ID starting with @cf/cloudflare/clef, and TypeSafe's form with the opt-in header otherwise. A custom provider that reaches OpenRouter through a proxy matches neither check and must set compat.imageInput: "state-image-urls" and compat.sendImageOptInHeader: false; otherwise OpenRouter drops its images.

Vercel AI Gateway and OpenCode list their Jev models with text input only.

OpenAI Decisions

The openai-decisions API calls OpenAI's Decisions API (POST /v1/decisions). GPT-6 Luna is its only model. The state is sent as JSON text; with images, the input is one user message with the state followed by the images as data URLs (at most 128). choice and score questions map to the Decisions types of the same name. bool questions become predicate questions; predicates have no criteria field, so the meanings of true and false are appended to the instructions. If OpenAI refuses to answer a question, the whole result is an error. Usage reports input tokens only, priced at the catalog input rate with OpenAI's long-context multiplier.

The endpoint needs an OpenAI API key; Sign in with ChatGPT credentials are rejected, so the openai provider hides gpt-6-luna from getAvailableOfType('classifier') while it uses OAuth. The API rejects inputs above 922K tokens, but requests that run longer than about five seconds, currently above roughly 600K input tokens, fail with a gateway timeout (504).

Chat models on llama.cpp

llama.cpp also serves decision models such as Julia-1 or Kev natively through /v1/systemone. Since llama.cpp 0.6.0, GET /models lists decisions in a model's architecture.output_modalities for these models. They use the typesafe-system-one API with the server's /v1 URL as baseUrl (for example http://127.0.0.1:8080/v1); it needs an apiKey, which llama.cpp ignores unless it was started with --api-key. The llama-cpp-classify API is the fallback for chat models.

The llama-cpp-classify API turns a chat model served by llama.cpp's llama-server into a classifier. Each question becomes one chat prompt: the state, every question of the request, the state again, and the question with its answers under single-token labels (letters for a choice, Yes/No for a bool, digits for a score). The prompt up to the final question is shared by all questions of a request, so the server's prompt cache evaluates the state once per request. The server returns the log-probabilities of the next token, and the answer is the softmax over the label tokens. Choices support up to 62 options and scores up to 10 levels. The model's baseUrl is the server URL; a trailing /v1 is ignored. In router mode, the model ID selects the model.

typescript
import { createProvider } from '@earendil-works/pi-ai';
import { llamaCppClassifyApi } from '@earendil-works/pi-ai/api/llama-cpp-classify.lazy';

const provider = createProvider({
  id: 'local-llama',
  auth: { apiKey: { name: 'llama.cpp', resolve: async () => ({ auth: {} }) } },
  models: [{
    type: 'classifier',
    id: 'qwen3-4b',
    name: 'Qwen3 4B',
    api: 'llama-cpp-classify',
    provider: 'local-llama',
    baseUrl: 'http://127.0.0.1:8080',
    input: ['text'],
    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
    contextWindow: 32768
  }],
  classifiers: { 'llama-cpp-classify': llamaCppClassifyApi() }
});

Raw label probabilities are usually overconfident; pass temperature above 1 to soften them.

Custom providers register classifier models and implementations by API ID:

typescript
createProvider({
  id: 'classifier-service',
  auth,
  models: [model],
  classifiers: {
    'classifier-api': { classify: async (model, context, options) => result }
  }
});

Thinking/Reasoning

Many models support thinking/reasoning capabilities where they can show their internal thought process. You can check if a model supports reasoning via the reasoning property. If you pass reasoning options to a non-reasoning model, they are silently ignored.

Unified Interface (streamSimple/completeSimple)

typescript
// Many models across providers support thinking/reasoning
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
// or models.getModel('openai', 'gpt-5-mini');
// or models.getModel('google', 'gemini-2.5-flash');
// or models.getModel('xai', 'grok-4.7');

// Check if model supports reasoning
if (model.reasoning) {
  console.log('Model supports reasoning/thinking');
}

// Use the simplified reasoning option
const response = await models.completeSimple(model, {
  messages: [{ role: 'user', content: 'Solve: 2x + 5 = 13', timestamp: Date.now() }]
}, {
  reasoning: 'medium'  // 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
});

// Access thinking and text blocks
for (const block of response.content) {
  if (block.type === 'thinking') {
    console.log('Thinking:', block.thinking);
  } else if (block.type === 'text') {
    console.log('Response:', block.text);
  }
}

xhigh and max are model-specific, opt-in levels. Use getSupportedThinkingLevels(model) to determine whether a concrete model exposes either level; models such as GPT-5.6 can expose both.

Provider-Specific Options (stream/complete)

models.stream()/complete() accept the owning API's full option set. Use hasApi() to narrow a dynamically looked-up model to its API for full option typing:

typescript
import { hasApi } from '@earendil-works/pi-ai';

// OpenAI Reasoning (o1, o3, gpt-5)
const openaiModel = models.getModel('openai', 'gpt-5-mini')!;
if (hasApi(openaiModel, 'openai-responses')) {
  await models.complete(openaiModel, context, {
    reasoningEffort: 'medium',
    reasoningSummary: 'detailed'  // OpenAI Responses API only
  });
}

// Anthropic Thinking
const anthropicModel = models.getModel('anthropic', 'claude-sonnet-4-5')!;
if (hasApi(anthropicModel, 'anthropic-messages')) {
  await models.complete(anthropicModel, context, {
    thinkingEnabled: true,
    thinkingBudgetTokens: 8192  // Optional token limit
  });
}

// Google Gemini Thinking
const googleModel = models.getModel('google', 'gemini-2.5-flash')!;
if (hasApi(googleModel, 'google-generative-ai')) {
  await models.complete(googleModel, context, {
    thinking: {
      enabled: true,
      budgetTokens: 8192  // -1 for dynamic, 0 to disable
    }
  });
}

Streaming Thinking Content

When streaming, thinking content is delivered through specific events:

typescript
const s = models.streamSimple(model, context, { reasoning: 'high' });

for await (const event of s) {
  switch (event.type) {
    case 'thinking_start':
      console.log('[Model started thinking]');
      break;
    case 'thinking_delta':
      process.stdout.write(event.delta);  // Stream thinking content
      break;
    case 'thinking_end':
      console.log('\n[Thinking complete]');
      break;
  }
}

Stop Reasons

Every AssistantMessage includes a stopReason field that indicates how the generation ended:

  • "pending" - Only present in partial messages when we do not know what the stop reason will be
  • "stop" - This is the final message the model will produce this turn
  • "length" - Output hit the maximum token limit
  • "toolUse" - Model is calling tools and expects tool results
  • "error" - An error occurred during generation
  • "aborted" - Request was cancelled via abort signal

AssistantMessage may also include responseId, a provider-specific upstream response or message identifier when the underlying API exposes one. Do not assume it is always present across providers.

Telemetry

Pass an explicit parent span (TelemetrySpan, not an adapter) through telemetry; omitting it uses the no-op span:

typescript
import { InMemoryTelemetryContext } from '@earendil-works/pi-telemetry';

const telemetry = new InMemoryTelemetryContext();
await telemetry.startSpan({ name: 'application.turn' }, async (span) => {
  await models.completeSimple(model, context, { telemetry: span });
});

Models.stream() and streamSimple() create pi.ai.request; streamDeferred() creates one pi.ai.poll per poll. Their completion helpers use the same spans. These spans include auth/setup, end at the terminal result, and do not require the caller to consume events. Direct provider/API calls are not instrumented.

AI_TELEMETRY_SCHEMA, exported from the root and @earendil-works/pi-ai/models, defines the typed Pi-owned vocabulary:

  • Start attributes: provider, api, model, and reasoning only when options.reasoning is a string. Provider-native reasoning objects are ignored.
  • End attributes: stop, usage.input, usage.output, usage.cache_read, usage.cache_write, cost.total, and optional response.id. stop: "error" sets error status with the fixed category provider_error and AssistantMessage.errorMessage truncated to 500 characters (empty when absent). Cancellation keeps status ok with stop: "aborted".
  • pi.ai.first_chunk marks the first normalized event, including setup errors. Each completed text, thinking, or tool-call block emits pi.ai.block with type, index, and chars; only tool calls include tool. Character counts use JavaScript string length; tool counts sum argument delta lengths without serializing arguments.

When options.telemetry.metrics is available, the same requests record metrics:

Metric Unit Attributes
pi.ai.request.duration ms provider, model, stop
pi.ai.first_chunk.duration ms provider, model
pi.ai.tokens token provider, model, kind (input, output, cache_read, cache_write)
pi.ai.cost USD provider, model

Durations use performance.now(). Pending/deferred polls do not add tokens or cost; final completed/error polls do. A parent span without metric capabilities remains valid and simply records no metrics. Adapter-created spans carry the same optional metric and propagation capabilities as their root.

Telemetry does not record prompts, generated text, reasoning content, or arguments in attributes/events. Error status includes up to 500 characters of provider error text, which may contain sensitive data; apply your export policy accordingly. Telemetry observes the existing forwarding path without another stream consumer or per-delta events. Response identifiers and error messages never become metric attributes.

Error Handling

Request failures after a stream is returned never throw: when a request ends with an error (including aborts and tool call validation errors), the streaming API emits an error event and the final message carries the details. Setup failures may emit error without start; failures after generation begins emit start, any observed updates, then error. Direct API streamSimple() calls throw synchronously when request auth is missing:

typescript
// In streaming
for await (const event of s) {
  if (event.type === 'error') {
    // event.reason is either "error" or "aborted"
    // event.error is the AssistantMessage with partial content
    console.error(`Error (${event.reason}):`, event.error.errorMessage);
    console.log('Partial content:', event.error.content);
  }
}

// The final message will have the error details
const message = await s.result();
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
  console.error('Request failed:', message.errorMessage);
  // message.content contains any partial content received before the error
  // message.usage contains partial token counts and costs
}

When using a provider collection, auth failures (OAuth refresh failed, unknown provider) surface as a stream error with stopReason: "error". Direct API streamSimple() calls instead throw synchronously when their required auth is absent.

Aborting Requests

The abort signal allows you to cancel in-progress requests. Aborted requests have stopReason === 'aborted':

typescript
const controller = new AbortController();

// Abort after 2 seconds
setTimeout(() => controller.abort(), 2000);

const s = models.stream(model, {
  messages: [{ role: 'user', content: 'Write a long story', timestamp: Date.now() }]
}, {
  signal: controller.signal
});

for await (const event of s) {
  if (event.type === 'text_delta') {
    process.stdout.write(event.delta);
  } else if (event.type === 'error') {
    // event.reason tells you if it was "error" or "aborted"
    console.log(`${event.reason === 'aborted' ? 'Aborted' : 'Error'}:`, event.error.errorMessage);
  }
}

// Get results (may be partial if aborted)
const response = await s.result();
if (response.stopReason === 'aborted') {
  console.log('Request was aborted:', response.errorMessage);
  console.log('Partial content received:', response.content);
  console.log('Tokens used:', response.usage);
}

Continuing After Abort

Aborted messages can be added to the conversation context and continued in subsequent requests:

typescript
const context = {
  messages: [
    { role: 'user', content: 'Explain quantum computing in detail', timestamp: Date.now() }
  ]
};

// First request gets aborted after 2 seconds
const controller1 = new AbortController();
setTimeout(() => controller1.abort(), 2000);

const partial = await models.complete(model, context, { signal: controller1.signal });

// Add the partial response to context
context.messages.push(partial);
context.messages.push({ role: 'user', content: 'Please continue', timestamp: Date.now() });

// Continue the conversation
const continuation = await models.complete(model, context);

Debugging Provider Payloads

Use the onPayload callback to inspect the request payload sent to the provider. This is useful for debugging request formatting issues or provider validation errors.

typescript
const response = await models.complete(model, context, {
  onPayload: (payload) => {
    console.log('Provider payload:', JSON.stringify(payload, null, 2));
  }
});

The callback is supported by stream, complete, streamSimple, and completeSimple.

Observing Provider Stream Events

Use onProviderStreamEvent to inspect provider-specific fields that Pi does not include in AssistantMessage. The callback receives the parsed event available to the adapter before Pi normalizes it. Treat the event as read-only because mutations can affect normalization. This is not guaranteed to be the original HTTP bytes or SSE frame.

typescript
const openRouterModel = models.getModel('openrouter', 'openrouter/auto')!;
const response = await models.complete(openRouterModel, context, {
  headers: { "X-OpenRouter-Metadata": "enabled" },
  onProviderStreamEvent: (data) => {
    const chunk = data as Record<string, unknown>;
    if (chunk.openrouter_metadata) {
      console.log(chunk.openrouter_metadata);
    }
  },
});

Callbacks are awaited in stream order, so slow callbacks delay stream consumption and thrown errors fail the request. SDK-backed adapters can expose only fields retained by their SDK.

Custom Providers

createProvider()

createProvider() builds a provider from parts: identity, auth, a model list, and an API implementation (api for chat models, images for image generation, classifiers for classification; at least one is required, see Image Generation). Use it for local inference servers, proxies, or any OpenAI/Anthropic-compatible endpoint:

typescript
import { createModels, createProvider, envApiKeyAuth, type Model } from '@earendil-works/pi-ai';
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';

const ollamaModel: Model<'openai-completions'> = {
  id: 'llama-3.1-8b',
  name: 'Llama 3.1 8B (Ollama)',
  api: 'openai-completions',
  provider: 'ollama',
  baseUrl: 'http://localhost:11434/v1',
  reasoning: false,
  input: ['text'],
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  contextWindow: 128000,
  maxTokens: 32000
};

const ollama = createProvider({
  id: 'ollama',
  name: 'Ollama',
  baseUrl: 'http://localhost:11434/v1',
  // Every provider declares auth; keyless local servers resolve as configured with no key.
  auth: { apiKey: { name: 'Ollama', resolve: async () => ({ auth: {} }) } },
  models: [ollamaModel],
  api: openAICompletionsApi(),
});

const models = createModels();
models.setProvider(ollama);

await models.complete(models.getModel('ollama', 'llama-3.1-8b')!, context);

For providers with real keys, envApiKeyAuth(displayName, envVars) gives the standard behavior (stored credential wins, then the first set env var):

typescript
const proxy = createProvider({
  id: 'my-proxy',
  auth: { apiKey: envApiKeyAuth('My proxy API key', ['MY_PROXY_API_KEY']) },
  models: [/* ... */],
  api: openAICompletionsApi(),
});

Mixed-API providers pass a map keyed by model.api; each model dispatches to its API's implementation:

typescript
import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy';

const gateway = createProvider({
  id: 'my-gateway',
  auth: { apiKey: envApiKeyAuth('Gateway key', ['GATEWAY_API_KEY']) },
  models: [/* models with api: 'anthropic-messages' or 'openai-responses' */],
  api: {
    'anthropic-messages': anthropicMessagesApi(),
    'openai-responses': openAIResponsesApi(),
  },
});

Provider-wide endpoint or request transformations belong in the provider's API implementation: wrap the ProviderStreams you pass as api so every request goes through the transformation before dispatch. The Cloudflare providers do this to materialize account/gateway endpoint placeholders from the resolved provider env:

typescript
function tenantStreams(streams: ProviderStreams): ProviderStreams {
  const withTenant = (model: Model<Api>) => ({ ...model, baseUrl: model.baseUrl.replace('{tenant}', tenantId) });
  return {
    stream: (model, context, options) => streams.stream(withTenant(model), context, options),
    streamSimple: (model, context, options) => streams.streamSimple(withTenant(model), context, options),
  };
}

const tenantGateway = createProvider({
  id: 'tenant-gateway',
  auth: { apiKey: envApiKeyAuth('Gateway key', ['GATEWAY_API_KEY']) },
  models: [/* ... */],
  api: tenantStreams(openAICompletionsApi()),
});

Dynamic model lists use fetchModels, which can return models of every type. Models.refresh() refreshes every configured dynamic provider, passing its effective API-key or refreshed OAuth credential. A ModelsStore persists dynamic catalogs; both stores default to in-memory implementations. Its read, write, and delete operations accept optional cancellation, and Models binds those waits to the provider refresh signal.

typescript
const models = createModels({ credentials, modelsStore });
const llamacpp = createProvider({
  id: 'llamacpp',
  auth: { apiKey: { name: 'llama.cpp', resolve: async () => ({ auth: {} }) } },
  models: [],
  fetchModels: async ({ signal }) => fetchModelsFromServer('http://localhost:8080', signal),
  api: openAICompletionsApi(),
});

models.setProvider(llamacpp);
const result = await models.refresh({ signal });
if (result.aborted) console.log('refresh cancelled');
for (const [provider, error] of result.errors) console.error(provider, error);

Models.refresh() is unbounded when its optional signal is omitted. Providers always receive a concrete RefreshModelsContext.signal and must honor it for network requests and other blocking work. When a caller supplies a signal, Models.refresh() returns promptly with aborted: true after cancellation even if a custom provider fails to cooperate; the provider must still honor the signal to stop its underlying work.

Use models.refresh({ providers: ['openrouter'] }) to restrict work to selected providers, models.refresh({ allowNetwork: false }) to restore persisted catalogs without network access, or models.refresh({ force: true }) to bypass provider freshness checks. Model reads stay synchronous and return the last restored or refreshed list.

createProvider() handles dynamic publication and persistence automatically. Handwritten Provider.refreshModels() implementations receive the read-only context.stored snapshot and publish through context.publish({ persist?, update? }). Omit persist to leave storage unchanged, pass a ModelsStoreEntry to write it, or pass persist: null to delete it. ModelsStoreEntry.models contains models of every type. Publication is generation-checked; put synchronous in-memory catalog changes in update rather than mutating state before publication.

Custom models can carry headers (e.g. proxies behind bot detection) and compat flags. Models.getAuth(model) includes those model headers, and stream methods merge them before explicit request headers and transformHeaders. See OpenAI Compatibility Settings.

Some OpenAI-compatible servers do not understand the developer role used for reasoning-capable models. For those providers, set compat.supportsDeveloperRole to false so the system prompt is sent as a system message instead. If the server also does not support reasoning_effort, set compat.supportsReasoningEffort to false too. This commonly applies to Ollama, vLLM, SGLang, and similar OpenAI-compatible servers.

Use model-level thinkingLevelMap to describe model-specific thinking controls. Keys are pi thinking levels (off, minimal, low, medium, high, xhigh, max). Missing standard levels through high use provider defaults; xhigh and max are opt-in and require a non-null map entry. String values are sent to the provider, null marks a level unsupported, and maps may skip levels.

typescript
const ollamaReasoningModel: Model<'openai-completions'> = {
  id: 'gpt-oss:20b',
  name: 'GPT-OSS 20B (Ollama)',
  api: 'openai-completions',
  provider: 'ollama',
  baseUrl: 'http://localhost:11434/v1',
  reasoning: true,
  input: ['text'],
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  contextWindow: 131072,
  maxTokens: 32000,
  thinkingLevelMap: {
    minimal: null,
    low: null,
    medium: null,
    high: 'high',
    xhigh: null,
  },
  compat: {
    supportsDeveloperRole: false,
    supportsReasoningEffort: false,
  }
};

Calling API Implementations Directly

The API implementations are importable on their own. Each module exports exactly stream and streamSimple with that API's full option typing. Direct calls bypass provider auth and context normalization — pass apiKey explicitly and wrap the context in normalizeContext():

typescript
import { normalizeContext } from '@earendil-works/pi-ai';
import { stream } from '@earendil-works/pi-ai/api/anthropic-messages';

const s = stream(claudeModel, normalizeContext(context), {
  apiKey: process.env.ANTHROPIC_API_KEY,
  thinkingEnabled: true,
  thinkingBudgetTokens: 2048,
});

Built-in API implementations live under ./api/<api-id>:

API id Options type
anthropic-messages AnthropicOptions
openai-completions OpenAICompletionsOptions
openai-responses OpenAIResponsesOptions
openai-codex-responses OpenAICodexResponsesOptions
azure-openai-responses AzureOpenAIResponsesOptions
google-generative-ai GoogleOptions
google-vertex GoogleVertexOptions
mistral-conversations MistralOptions
bedrock-converse-stream BedrockOptions

Importing an implementation module loads its SDK. The ./api/.lazy wrappers (used by the provider factories) defer that load to the first request when the runtime or bundler supports dynamic import chunking. Legacy raw API subpaths from older releases (./anthropic, ./google, ./mistral, ./openai-completions, ...) were removed; use @earendil-works/pi-ai/api/<api-id>.

OpenAI Compatibility Settings

The openai-completions API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on baseUrl for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the compat field. For openai-responses models, the compat field supports Responses-specific flags.

text
/* Detailed source-code truncated for AI context efficiency. */

OpenRouter requests send x-session-id from sessionId when prompt caching is enabled. Chat Completions and Anthropic Messages both auto-detect OpenRouter endpoints unless sendSessionAffinityHeaders is explicitly false. On Anthropic-compatible models, sessionAffinityFormat: "openrouter" selects x-session-id; when unset, the existing x-session-affinity format is used. Explicit request headers take precedence over generated headers.

If compat is not set, the library falls back to URL-based detection. If compat is partially set, unspecified fields use the detected defaults. This is useful for:

  • LiteLLM proxies: May not support store field
  • Custom inference servers: May use non-standard field names
  • Self-hosted endpoints: May have different feature support

Faux Provider for Tests

fauxProvider() builds an in-memory provider with scripted responses for tests and demos:

text
/* Detailed source-code truncated for AI context efficiency. */

Notes:

  • Responses are consumed from a queue in request start order.
  • If the queue is empty, the faux provider returns an assistant error message with errorMessage: "No more faux responses queued".
  • Use faux.setResponses([...]) to replace the remaining queue and faux.appendResponses([...]) to add more responses.
  • faux.models exposes all faux models. faux.getModel() returns the first one, and faux.getModel(id) returns a specific one.
  • Use fauxAssistantMessage(...) for scripted assistant replies. Use fauxText(...), fauxThinking(...), and fauxToolCall(...) to build content blocks without filling in low-level fields manually.
  • Usage is estimated at roughly 1 token per 4 characters. When sessionId is present and cacheRetention is not "none", prompt cache reads and writes are simulated automatically.
  • Tool call arguments stream incrementally via toolcall_delta chunks.
  • By default, each streamed chunk is emitted on its own microtask. Set tokensPerSecond to pace chunk delivery in real time.
  • The intended use is one deterministic scripted flow per handle. If you need independent concurrent flows, create separate faux providers with distinct provider ids.

Cross-Provider Handoffs

The library supports seamless handoffs between different LLM providers within the same conversation. This allows you to switch models mid-conversation while preserving context, including text, tool calls, and tool results.

When messages from one provider are sent to a different provider, the library automatically transforms them for compatibility:

  • User and tool result messages are passed through unchanged
  • Assistant messages from the same provider, API, and model are preserved as-is, including thinking blocks
  • Assistant messages from a different model have their thinking blocks dropped. Signatures and encrypted reasoning only work with the model that produced them, and readable thinking sent as plain text makes the new model treat it as part of the reply
  • Tool calls and regular text are preserved unchanged
typescript
import { createModels, type Context } from '@earendil-works/pi-ai';
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { googleProvider } from '@earendil-works/pi-ai/providers/google';

const models = createModels();
models.setProvider(anthropicProvider());
models.setProvider(openaiProvider());
models.setProvider(googleProvider());

const context: Context = { messages: [] };

// Start with Claude
const claude = models.getModel('anthropic', 'claude-sonnet-4-5')!;
context.messages.push({ role: 'user', content: 'What is 25 * 18?', timestamp: Date.now() });
context.messages.push(await models.completeSimple(claude, context, { reasoning: 'medium' }));

// Switch to GPT-5 - it sees Claude's answer, but not Claude's thinking
const gpt5 = models.getModel('openai', 'gpt-5-mini')!;
context.messages.push({ role: 'user', content: 'Is that calculation correct?', timestamp: Date.now() });
context.messages.push(await models.complete(gpt5, context));

// Switch to Gemini
const gemini = models.getModel('google', 'gemini-2.5-flash')!;
context.messages.push({ role: 'user', content: 'What was the original question?', timestamp: Date.now() });
const geminiResponse = await models.complete(gemini, context);

All providers can handle messages from other providers — text, tool calls and results (including images), thinking blocks (dropped when the model changes), and aborted messages with partial content. This enables flexible workflows: start with a fast model, switch to a more capable one for complex reasoning, or maintain continuity across provider outages.

System Messages

Context.systemPrompt and Context.tools are shorthand for a leading system message. The public entry points (Models.stream(), streamSimple(), complete(), completeSimple()) accept a Context and call normalizeContext() once; everything below them, including Provider.stream(), ProviderStreams, and the API implementation modules, receives the resulting TranscriptContext, which only has messages. The transcript can also carry system messages later in the conversation to change the prompt or the tool set without rewriting the history:

typescript
interface SystemMessage {
  role: "system";
  content: string | TextContent[];             // leading: base prompt; later: added instructions
  sections?: Record<string, string | null>;    // named prompt sections; later messages patch by name, null removes
  toolsAdded?: Tool[];                         // tools that become available here
  toolsRemoved?: ToolReference[];              // tools that stop being available here
  timestamp: number;
}

Sections are opaque text rendered verbatim after content, joined by blank lines. Keep each one self-delimiting (a tag, a heading) so the model can relate an update to the original. Replaying every system message in order yields the current prompt and tools; the replay helpers take the message list:

typescript
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai";

const messages: Message[] = [
  { role: "system", content: "You are helpful.", sections: { rules: "<rules>Be brief.</rules>" }, toolsAdded: [readTool], timestamp: 1 },
  { role: "user", content: "hi", timestamp: 2 },
  { role: "system", content: "", sections: { rules: "<rules>Be thorough.</rules>" }, toolsRemoved: [{ name: "read" }], timestamp: 3 },
];
getCurrentSystemPrompt(messages); // "You are helpful.\n\n<rules>Be thorough.</rules>"
getCurrentTools(messages);        // []

A custom Provider or ProviderStreams implementation reads the prompt and tools the same way from context.messages; context.systemPrompt and context.tools do not exist at that layer.

Models that accept system messages mid-conversation (supportsMidConvoSystemMessages in the model's compat settings, set by the generated catalog for verified models) receive each later system message in place, so the cached prefix stays intact; section changes are framed by name for the model. Every other model receives collapseSystemMessages(transcript): the replayed prompt and current tools as the leading system message, with later system messages dropped. Anthropic models that also set supportsMidConvoToolChanges send tool changes as native blocks (inline-tools-2026-09-15 beta): the top-level tool list holds the initial tools plus a stable deferred placeholder and never changes, later tools are defined by value in tool_addition blocks (a new definition under an existing name replaces the old one), and removals are tool_removal references, so tool changes do not invalidate the prompt cache. That needs at least one initial tool; otherwise the current tool list is sent at the top level with the system text only. OpenAI Responses models with supportsAdditionalTools declare every tool in additional_tools items, like Codex: the initial tools in one at the head of the input and later additions and redefinitions at their message, with no top-level tool list. A later item replaces same-name definitions from earlier items but cannot replace a top-level tool or remove any tool, so removals are announced as text, later updates restate the tools that are still unavailable, and calls to removed tools fail at execution. OpenAI Responses models with only supportsToolSearch, and Kimi models with supportsMidConvoToolAdditions, anchor new tools at their message; a removal or a redeclared name moves the tool state up to that point into the top-level list once. Everything else sends the current tool list at the top level.

Context Serialization

The Context object can be easily serialized and deserialized using standard JSON methods, making it simple to persist conversations, implement chat history, or transfer contexts between services:

typescript
const context: Context = {
  systemPrompt: 'You are a helpful assistant.',
  messages: [
    { role: 'user', content: 'What is TypeScript?', timestamp: Date.now() }
  ]
};

const model = models.getModel('openai', 'gpt-4o-mini')!;
const response = await models.complete(model, context);
context.messages.push(response);

// Serialize the entire context
const serialized = JSON.stringify(context);

// Save to database, localStorage, file, etc.
localStorage.setItem('conversation', serialized);

// Later: deserialize and continue the conversation
const restored: Context = JSON.parse(localStorage.getItem('conversation')!);
restored.messages.push({ role: 'user', content: 'Tell me more about its type system', timestamp: Date.now() });

// Continue with any model
const newModel = models.getModel('anthropic', 'claude-3-5-haiku-20241022')!;
const continuation = await models.complete(newModel, restored);

Models are plain serializable data too — no functions or implementations attached — so persisting "which model was this conversation using" is a JSON.stringify away.

Note: If the context contains images (encoded as base64 as shown in the Image Input section), those will also be serialized.

Browser Usage

The library supports browser environments. The core entrypoint and provider factories are side-effect free and bundle cleanly. Environment variables are not available in browsers, so pass API keys explicitly — or inject a CredentialStore (e.g. localStorage-backed) and let provider auth resolve from stored credentials:

typescript
import { createModels } from '@earendil-works/pi-ai';
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';

const models = createModels();
models.setProvider(anthropicProvider());

const model = models.getModel('anthropic', 'claude-3-5-haiku-20241022')!;
const response = await models.complete(model, {
  messages: [{ role: 'user', content: 'Hello!', timestamp: Date.now() }]
}, {
  apiKey: 'your-api-key'
});

Security Warning: Exposing API keys in frontend code is dangerous. Anyone can extract and abuse your keys. Only use this approach for internal tools or demos. For production applications, use a backend proxy that keeps your API keys secure.

Browser compatibility notes:

  • Amazon Bedrock (bedrock-converse-stream) is not supported in browser environments. It can still appear in model lists; calls fail at runtime.
  • OAuth login flows are Node-only. They are lazy-loaded behind bundler-opaque imports, so registering an OAuth-capable provider does not pull Node-only code into a browser bundle — only actually logging in would.
  • Use a server-side proxy or backend service if you need Bedrock or OAuth-based auth from a web app.

Bundling and Tree Shaking

For small bundles and low-overhead unbundled scripts, import the model runtime and only the providers you need:

typescript
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';

const models = createModels();
models.setProvider(openaiProvider());

Rules:

  • @earendil-works/pi-ai/models exports the model runtime (createModels, createProvider, model helpers, and their types) without TypeBox, built-in catalogs, or SDK implementations. Other types can still use import type from the root.
  • @earendil-works/pi-ai is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports TypeBox and schema validation. Unbundled Node scripts do not tree-shake its unused exports; prefer ./models, ./providers/faux, and specific ./utils/* subpaths when those are all you need.
  • @earendil-works/pi-ai/providers/ imports that provider's catalog and lazy API wrapper only.
  • @earendil-works/pi-ai/providers/all imports every built-in provider factory and all catalogs. Use it only when you want the full built-in set.
  • With code splitting, provider SDKs stay in lazy chunks and load on first request.
  • Without code splitting, bundlers fold reachable lazy API implementations into the single bundle. A single-provider bundle then includes that provider's SDK; providers/all includes all statically visible SDKs. Bedrock is the exception: its AWS SDK implementation is loaded through a bundler-opaque Node-only import.
  • Importing @earendil-works/pi-ai/api/<api-id> directly loads that API implementation and its SDK immediately.

Avoid @earendil-works/pi-ai/compat in new bundled apps; it preserves the old global API and imports the full built-in catalog surface.

For single-file Node ESM bundles, some SDK dependencies may still use dynamic CommonJS require() internally. If you see errors such as Dynamic require of "child_process" is not supported, add a Node require shim to the bundle. With esbuild:

bash
esbuild app.js --bundle --platform=node --format=esm \
  --banner:js='import { createRequire } from "module";const require = createRequire(import.meta.url);' \
  --outfile=app.bundle.js

This is only for Node bundles; it is not a browser or Cloudflare Workers workaround.

Bedrock is Node-only. Add it like any other provider:

typescript
import { createModels } from '@earendil-works/pi-ai';
import { amazonBedrockProvider } from '@earendil-works/pi-ai/providers/amazon-bedrock';

const models = createModels();
models.setProvider(amazonBedrockProvider());

In normal Node package usage and code-split bundles, Bedrock loads its AWS SDK implementation lazily. For a standalone single-file bundle that must include Bedrock support, register the implementation module explicitly:

typescript
import { setBedrockProviderModule } from '@earendil-works/pi-ai/api/bedrock-converse-stream.lazy';
import { bedrockProviderModule } from '@earendil-works/pi-ai/bedrock-provider';

setBedrockProviderModule(bedrockProviderModule);

That explicit override bundles the AWS SDK. Without it, Bedrock's opaque runtime import expects the package's Bedrock implementation file to be available at runtime.

Provider-Scoped Environment Overrides

Pass env in stream options to scope provider configuration to a request. Values in env are used before process environment variables for provider auth and configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, PI_CACHE_RETENTION, and HTTP_PROXY/HTTPS_PROXY.

typescript
const models = builtinModels();
const model = models.getModel('cloudflare-ai-gateway', 'workers-ai/@cf/moonshotai/kimi-k2.6')!;

const response = await models.complete(model, context, {
  env: {
    CLOUDFLARE_API_KEY: '...',
    CLOUDFLARE_ACCOUNT_ID: 'account-id',
    CLOUDFLARE_GATEWAY_ID: 'gateway-id'
  }
});

Use this when one process needs different provider settings per request, or when ambient environment variables should not leak into a provider call.

OAuth Providers

Several providers support OAuth authentication instead of static API keys:

  • Anthropic (Claude Pro/Max subscription)
  • OpenAI (Sign in with ChatGPT: uses the ChatGPT subscription with the OpenAI API)
  • OpenAI Codex (legacy) (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
  • GitHub Copilot (Copilot subscription)
  • OpenRouter (OAuth PKCE that mints a user-controlled API key)

Each of these providers carries an OAuthAuth on provider.auth.oauth with three operations: login(interaction) uses the provider-neutral AuthInteraction.prompt()/notify() protocol and returns a credential, refresh(credential, signal) refreshes expiring credentials when applicable, and toAuth(credential) derives request auth (GitHub Copilot's per-account base URL comes from here). Provider login interactions and refresh calls always carry a concrete abort signal. Refresh is automatic: models.getAuth(providerId) and request paths refresh expired tokens under a credential-store lock, so concurrent requests and processes cannot double-refresh. OpenRouter's OAuth flow instead returns a permanent API key, so its refresh operation is a no-op.

typescript
import { createModels } from '@earendil-works/pi-ai';
import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';

const models = createModels({ credentials: myStore }); // persistent CredentialStore
models.setProvider(anthropicProvider());

// Login: Models drives the flow and persists the credential
await models.login('anthropic', 'oauth', {
  prompt: async (p) => {
    // p.type: 'text' | 'secret' | 'select' | 'manual_code'
    // manual_code prompts race a local callback server; p.signal aborts them when the server wins
    return await askUser(p.message);
  },
  notify: (event) => {
    // event.type: 'info' | 'auth_url' | 'device_code' | 'progress'
    if (event.type === 'info') {
      console.log(event.message);
      for (const link of event.links ?? []) console.log(`${link.label ?? 'More information'}: ${link.url}`);
    }
    if (event.type === 'auth_url') console.log(`Open: ${event.url}`);
    if (event.type === 'device_code') console.log(`Code: ${event.userCode} at ${event.verificationUri}`);
    if (event.type === 'progress') console.log(event.message);
  },
});

// From here on, requests resolve and refresh the token automatically
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
await models.complete(model, context);

// Logout
await models.logout('anthropic');

Vertex AI

Vertex AI models support either a Google Cloud API key or Application Default Credentials (ADC). Its provider-owned API-key login flow can configure either method:

  • API key: Set GOOGLE_CLOUD_API_KEY or pass apiKey in the call options.
  • Local development (ADC): Run gcloud auth application-default login
  • CI/Production (ADC): Set GOOGLE_APPLICATION_CREDENTIALS to point to a service account JSON key file

When using ADC, also set GOOGLE_CLOUD_PROJECT (or GCLOUD_PROJECT) and GOOGLE_CLOUD_LOCATION. You can also pass project/location in the call options. When using GOOGLE_CLOUD_API_KEY, project and location are not required.

bash
# Local (uses your user credentials)
gcloud auth application-default login
export GOOGLE_CLOUD_PROJECT="my-project"
export GOOGLE_CLOUD_LOCATION="us-central1"

# CI/Production (service account key file)
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"

Official docs: Application Default Credentials

CLI Login

The quickest way to authenticate:

bash
npx @earendil-works/pi-ai login              # interactive provider selection
npx @earendil-works/pi-ai login anthropic    # login to specific provider
npx @earendil-works/pi-ai list               # list available providers

Credentials are saved to auth.json in the current directory.

Programmatic OAuth

Built-in login and refresh flows are private provider implementations. Use provider-owned OAuthAuth, which composes with CredentialStore and gets locked auto-refresh through Models. The @earendil-works/pi-ai/oauth entry point retains only type declarations required by coding-agent extension OAuth compatibility.

Provider notes:

OpenAI Codex (legacy): Superseded by Sign in with ChatGPT on the OpenAI provider. Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when sessionId is provided in stream options unless cacheRetention is "none". You can set transport in stream options to "sse", "websocket", or "auto" for Codex Responses transport selection. When using WebSocket with a sessionId and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity. Call cleanupSessionResources(sessionId) when finished so the pooled connection does not keep the process alive.

Azure OpenAI (Responses): Uses the Responses API only. Set AZURE_OPENAI_API_KEY and either AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME. AZURE_OPENAI_BASE_URL supports both https://<resource>.openai.azure.com and https://<resource>.cognitiveservices.azure.com; root endpoints are normalized to .../openai/v1 automatically. Use AZURE_OPENAI_API_VERSION (defaults to v1) to override the API version if needed. Deployment names are treated as model IDs by default, override with azureDeploymentName or AZURE_OPENAI_DEPLOYMENT_NAME_MAP using comma-separated model-id=deployment pairs (for example gpt-4o-mini=my-deployment,gpt-4o=prod). Legacy deployment-based URLs are intentionally unsupported.

GitHub Copilot: If you get "The requested model is not supported" error, enable the model manually in VS Code: open Copilot Chat, click the model selector, select the model (warning icon), and click "Enable".

Migrating from the Old Global API

Older versions exposed a global API: stream()/complete() dispatching on model.api via a global registry, sync getModel()/getModels()/getProviders() catalog reads, registerApiProvider(), getEnvApiKey(), and per-API lazy stream functions. That surface lives unchanged on the compat entrypoint:

typescript
// Before
import { getModel, complete } from '@earendil-works/pi-ai';

// After (verbatim behavior, one import-path change)
import { getModel, complete } from '@earendil-works/pi-ai/compat';

Compat is a strict superset of the root entrypoint, so a file can switch its import path wholesale. It will be removed in a future release; migrate to createModels() + provider factories:

Old New
getModel('openai', 'gpt-4o-mini') models.getModel('openai', 'gpt-4o-mini') or getBuiltinModel() from providers/all
getModels('anthropic') / getProviders() models.getModels('anthropic') / models.getProviders() or getBuiltin*
stream(model, ctx, opts) (env-key injection) models.stream(model, ctx, opts) (provider auth resolution)
registerApiProvider({ api, stream, streamSimple }) createProvider({ id, auth, models, api }) + models.setProvider()
getEnvApiKey('openai') await models.getAuth(model.provider)
streamAnthropic(model, ctx, opts) stream from @earendil-works/pi-ai/api/anthropic-messages, or a provider in a collection
registerFauxProvider() fauxProvider() + models.setProvider()
getImageModel('openrouter', id) / generateImages(model, ctx, { apiKey }) models.getModelOfType('image', 'openrouter', id) / models.generateImages(model, ctx)

The separate ImagesModels/ImagesProvider collection that existed briefly (createImagesModels(), createImagesProvider(), openrouterImagesProvider(), builtinImagesModels()) is gone: image models now live on the regular provider. Replace builtinImagesModels() with builtinModels(), imagesModels.getModel() with models.getModelOfType('image', ...), and createImagesProvider({ models, api }) with createProvider({ models, images }). The old plural image type names are removed; use ImageModel and ImageApi, and add type: "image" to image model literals.

Development

Adding a New Provider

Adding a new LLM provider requires changes across multiple files. The layered layout: API implementations live in src/api/, provider factories in src/providers/, stable generated catalog wrappers live in src/providers/.models.ts, and src/models.generated.ts registers them. This checklist covers all necessary steps:

1. Core Types (`src/types.ts`)

  • Add the API identifier to KnownApi (for example "bedrock-converse-stream"), if it is a new API
  • Add the provider name to KnownProvider (for example "amazon-bedrock")
  • Add the options type to ApiOptionsMap

2. API Implementation (`src/api/.ts`, only for a new API)

Create a new API implementation file (for example bedrock-converse-stream.ts) that exports exactly stream and streamSimple, plus:

  • An options interface extending StreamOptions (for example BedrockOptions)
  • Message conversion functions to transform the TranscriptContext messages to provider format; read the prompt and tools from the transcript with getInitialSystemMessage(), getCurrentTools(), and resolveTranscript()
  • Tool conversion if the provider supports tools
  • Response parsing to emit standardized events (text, tool_call, thinking, usage, stop)

Add a lazy wrapper src/api/<api-id>.lazy.ts (<name>Api() via lazyApi()) so providers can reference the implementation without importing its SDK. Add any root-level export type re-exports in src/index.ts that should remain available from @earendil-works/pi-ai.

3. Model Generation (`scripts/generate-models.ts`)

  • Add logic to fetch and parse models from the provider's source (e.g., models.dev API)
  • Map chat/tool-capable provider data to Model, image-generation data to ImageModel, and models.dev type: "decision" entries to ClassifierModel; hydration groups the ignored src/providers/data/.json values by API while stable src/providers/.models.ts wrappers derive exact model/API types directly from those JSON keys
  • Keep model ids unique within each provider and model type; emit separate entries when an upstream model supports multiple operations
  • Handle provider-specific quirks (pricing format, capability flags, model ID transformations)

4. Provider Factory (`src/providers/.ts`)

  • createProvider() wiring catalog + auth + the lazy API wrapper
  • Auth: envApiKeyAuth for standard key providers, a custom ApiKeyAuth for ambient auth (AWS profiles, ADC), lazyOAuth where an OAuth flow exists
  • Register the factory in src/providers/all.ts
  • If it is a new API: register it in the builtin list in src/compat.ts and add the package subpath export in package.json

5. Tests (`test/`)

Create or update test files to cover the new provider:

  • stream.test.ts - Basic streaming and tool use
  • tokens.test.ts - Token usage reporting
  • abort.test.ts - Request cancellation
  • empty.test.ts - Empty message handling
  • context-overflow.test.ts - Context limit errors
  • image-limits.test.ts - Image support (if applicable)
  • unicode-surrogate.test.ts - Unicode handling
  • tool-call-without-result.test.ts - Orphaned tool calls
  • image-tool-result.test.ts - Images in tool results
  • total-tokens.test.ts - Token counting accuracy
  • cross-provider-handoff.test.ts - Cross-provider context replay
  • providers.test.ts - Provider listing and auth resolution

For cross-provider-handoff.test.ts, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family.

For providers with non-standard auth (AWS, Google Vertex), create a utility like bedrock-utils.ts with credential detection helpers.

6. Coding Agent Integration (`../coding-agent/`)

Update src/core/model-resolver.ts:

  • Add a default model ID for the provider in DEFAULT_MODELS

Update src/cli/args.ts:

  • Add environment variable documentation in the help text

Update README.md:

  • Add the provider to the providers section with setup instructions

7. Documentation

Update packages/ai/README.md:

  • Add to the Supported Providers table
  • Document any provider-specific options or authentication requirements
  • Add environment variable to the Environment Variables section

8. Changelog

Add an entry to packages/ai/CHANGELOG.md under ## [Unreleased]:

markdown
### Added
- Added support for [Provider Name] provider ([#PR](link) by [@author](link))

License

MIT


Overview (`packages/chord/README.md`)

@earendil-works/chord

Chord builds applications out of objects that live in several processes (a server, workers, browser tabs) and can be hot reloaded while they run.

  • An object is state plus code at an address. It is created on demand and lives while someone references it.
  • Everyone gets a reference, never the object. References work the same locally and across processes, and survive reloads.
  • Every object knows its principal: an owner and a guest get different objects, with different methods.
  • State is observable JSON. Remote viewers get a snapshot followed by deltas.
  • Hot reload swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.

Contracts are runtime values described with TypeBox schemas. Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.

Entry points

Import Contents Runs in
@earendil-works/chord Contracts (local, remote, method, opaque), declarations (through the factory's self: state, derive, resource, on, task), hosts (createHost, defineFacet, defineRegistry), links (memoryLinkPair, ipcLink, webSocketLink, acceptWebSockets), errors (ChordError, isChordError, forbidden, invalid). Also the shared base: Context, JsonValue, JsonRepresentation, Draft, copyJson, isJsonValue, and replicatedState with its types. Node and browsers
@earendil-works/chord/node Child host processes: createHostManager, forkDriver, childControl, processLock; the facet module loader nodeLoader. Node
@earendil-works/chord/context Explicit cancellation, telemetry, and invocation values: BACKGROUND_CONTEXT, withCancel, withAbortSignal, withoutAbortSignal, withTelemetry, traceIn, withContextValue, awaitWithContext. Node and browsers
@earendil-works/chord/delta The transactional JSON tracker, structural diff, immutable apply, and the wire encoder/decoder for operation batches. Node and browsers
@earendil-works/chord/output Bounded output streams and paced progress: OutputBuffer (head or tail window of a stream, OutputLimits, OutputSkip), boundOutput, sanitizeOutput, utf8ByteLength, and Progress (adaptive commits of a changing value). Node and browsers
@earendil-works/chord/telemetry-sqlite createSqliteTelemetry(db, { retentionMs?, maxQueuedSpans? }): buffered recording and backend-independent TelemetryStore queries. Any SQLite adapter
@earendil-works/chord/sqlite Portable SqliteDatabase, SqliteExecutor, and SqliteValue, atomic applySqliteMigrations(database, migrations, { table }), and validateSqliteSchemaPrefix. The metadata table must be a valid, non-reserved SQLite identifier; migration versions start at 1 and are contiguous. Any SQLite adapter
@earendil-works/chord/sqlite/node NodeSqliteDatabase, openNodeSqliteDatabase, and NodeSqliteDatabaseOptions. Node
@earendil-works/chord/sqlite/cloudflare DurableObjectSqliteDatabase and the structural DurableObjectSqliteStorage binding type. Cloudflare Durable Objects

Quick start

ts
import { Type } from "typebox";
import { createHost, hostNamed, method, remote } from "@earendil-works/chord";

// contract.ts: shared by provider and consumers
export const Counter = remote("demo.counter", {
	version: 1,
	input: Type.Object({ name: Type.String() }),
	host: hostNamed("server"), // where its objects live; callers never name a host
	variants: {
		default: {
			state: { count: Type.Number() },
			methods: { increment: method(Type.Object({ by: Type.Number() })) },
		},
	},
});

const host = createHost({ name: "server", self: { kind: "server" } });

host.serve(Counter, ({ name }, _principal, self) => {
	const count = self.state("count", Type.Number(), 0);
	return {
		variant: "default",
		count,
		increment: async ({ by }, ctx) => {
			count.change(ctx, (n) => n + by);
		},
	};
});

await host.root(async (ctx) => {
	const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends
	clicks.count.subscribe((n) => console.log("count", n));
	await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
});

Documentation

  • Tutorial: a shared todo list, built up section by section. Every example runs as test/tutorial.test.ts.
  • Usage guide: API reference for contracts, objects, state, roots, lifetime, plugins, hot reload, hosts, and links.
  • Specification: compatibility, lifetime, and reload semantics, plus known limitations and implementation decisions.
  • Delta guide: ownership, lifecycle, operation, and replica contracts of @earendil-works/chord/delta.

Explicit Context telemetry

Context always carries a telemetry: TelemetrySpan, alongside abortSignal. BACKGROUND_CONTEXT and TODO_CONTEXT use the shared no-op span NOOP_TELEMETRY_CONTEXT. Adapters (TelemetryContext) belong to hosts and start root spans; adapters are never placed in a Context.

ts
import { BACKGROUND_CONTEXT, traceIn, withTelemetry } from '@earendil-works/chord/context';
import { InMemoryTelemetryContext } from '@earendil-works/pi-telemetry';

const telemetry = new InMemoryTelemetryContext();
await telemetry.startSpan({ name: 'app.request' }, async (span) => {
  const context = withTelemetry(span, BACKGROUND_CONTEXT);
  await traceIn(context, { name: 'app.load' }, async (child) => {
    child.telemetry.addEvent('cache.miss');
    return traceIn(child, { name: 'app.read' }, (nested) => readAccount(nested));
  });
});

withTelemetry(span, context) derives a new context; it never changes its parent, cancellation, or typed values. traceIn(context, options, fn) creates a callback-managed child span, and fn receives its derived context. Every context already supports telemetry.addEvent() and telemetry.addLink(). Values, cancellation, callback results, and rejection identities are preserved. Concurrent calls keep independent parents; there is no ambient current span. value(), ContextKey, and withContextValue() remain supported.

These helpers operate on the standalone Context API. They do not yet instrument Chord hosts, methods, or network frames.

SQLite telemetry

ts
import { createSqliteTelemetry } from '@earendil-works/chord/telemetry-sqlite';
import { openNodeSqliteDatabase } from '@earendil-works/chord/sqlite/node';
import { renderTrace } from '@earendil-works/pi-telemetry/render';

const db = await openNodeSqliteDatabase('telemetry.db');
const telemetry = await createSqliteTelemetry(db);
try {
  await telemetry.startSpan({ name: 'app.request' }, async (span) => {
    span.addEvent('request.accepted');
    await span.startSpan({ name: 'app.read' }, () => readAccount());
  });
  await telemetry.flush();
  const { traces } = await telemetry.findTraces({ name: 'app.request', limit: 10 });
  for (const trace of traces) console.log(renderTrace(trace.spans));
} finally {
  try { await telemetry.close(); } finally { await db.close(); }
}

The adapter is portable over SqliteDatabase and owns no connection. Recording performs no SQL. A one-second timer flushes settled spans and completed ten-second metric intervals in one transaction, including retention cleanup. Metrics retain count, sum, minimum, and maximum per name/kind/unit/attribute series, not raw samples, buckets, or exemplars. close() stops recording and drains partial metric intervals without waiting for business callbacks or closing the database. Late span completions are ignored.

Retention defaults to seven days. maxQueuedSpans defaults to 10,000 and counts settled spans awaiting commit, including an in-flight batch. The same limit independently bounds pending metric interval/series pairs. Overflow increments droppedSpans or droppedMeasurements; the limits bound counts, not bytes or active spans. A write failure stops recording permanently: flush() and close() reject with the original error without retrying.

The adapter implements TelemetryStore: findTraces(), getTrace(), and findMetrics() read committed rows, including after adapter close while the database remains open. See stored telemetry for filters, pagination, and cross-process trace assembly. Schema tables are telemetry_spans, telemetry_metrics, and telemetry_schema.

Tracking JSON deltas

Import the standalone transactional tracker from @earendil-works/chord/delta:

ts
import { applyImmutable, track } from "@earendil-works/chord/delta";

const tracker = track({ output: "", count: 0 });
const change = tracker.beginChange();
change.state.output += "done\n";
change.state.count += 1;
const prepared = change.prepare();

tracker.adopt(prepared);
const replica = applyImmutable(prepared.base, prepared.ops);

tracker.value is always the latest adopted immutable revision. Preparation does
not change authority; adoption validates the preparation and swaps the root pointer.
Assigned containers are copied by value and unchanged subtrees may be shared between
revisions.

The tracker uses trusted immutable ownership rather than defensive copying or
freezing. track(initial), prepareReplace(value), replicatedState(initial), and
replace(context, value) take ownership of alias-free strict-JSON roots without
walking them. Callers must not mutate transferred or published data. Values placed
through drafts are already copied, so that walk also rejects non-strict JSON before
the draft changes. Published values are not frozen. In-process consumers may share
their containers with the provider; mutating a consumed value violates the contract
and can corrupt authority. Clone or serialize at any mutable trust boundary.

Replicated state provides the same model through a callback. replicatedState is a
standalone observable value, independent of hosts; Durable uses it for document,
view, and task-graph states:

ts
import { replicatedState } from "@earendil-works/chord";

const initial = { output: "", count: 0 };
const status = replicatedState(initial); // transfers ownership of initial
status.change(context, (draft) => {
	draft.output += "done\n";
	draft.count += 1;
});

A successful change() publishes exactly one atomic revision. If its callback
throws, the original value and sequence remain unchanged. Draft references become
unusable when the callback returns. The tracker emits string append and
front-truncate operations, array splices and permutations, sets, and deletes; large
edit sets may fold into a complete replacement.

replicatedState(source, { onError }) instead attaches a publication-only state to
an authoritative revision source (ReplicatedStateSource): it captures a snapshot,
then publishes every committed frame in cursor order.

Public state subscriptions

state.subscribe(async (value, context, delivery) => { ... }) serializes callbacks
independently for each subscription. Hydration starts immediately when a value is
available, and its returned promise must settle before updates start. Synchronous
callbacks still run synchronously. Read the captured value inside an asynchronous
callback: state.value may already refer to a later revision.

Each subscription retains at most 100 pending complete-value deliveries, excluding
the running callback. On overflow, only the newest pending value, context, and
delivery metadata are retained; a not-yet-started initial hydration is preserved.
Thus public delivery sequences may skip. This is a frame-count policy, not a byte
limit.

The returned unsubscribe function immediately discards pending work and prevents
new callbacks. It neither aborts nor waits for the running callback. Synchronous
throws and promise rejections are observed, reported, and do not stop other
subscriptions or subsequent callbacks. Attached sources use their onError
handler; mutable states (and attached sources without a handler) report failures
by throwing in a microtask. Unsubscribing does not hide a later rejection from the
running callback.

Cells of Chord objects and their remote readables have their own subscription
model; see State in the usage guide.


Overview (`packages/codemode/README.md`)

@earendil-works/pi-codemode

Runs model-written JavaScript in a QuickJS VM (compiled to WebAssembly) where the only capability is calling injected tools. Nested tool calls never enter the LLM context; only the script's output and return value do.

Scripts use tools, ALL_TOOLS, text, image, exit, store, and load, and may start with a // @options: line. The coding agent uses it for its built-in codemode tool. It has no pi dependencies and can be used on its own to expose any functions (remote APIs, MCP servers, application services) to model-written scripts.

The package runs in two kinds of environments. Each has its own sandbox class, but they share one API, one script environment, and one result format:

Environment Sandbox Import from Where scripts run
Node and Bun CodemodeSandbox @earendil-works/pi-codemode a worker thread per execution
Cloudflare Workers and Durable Objects RemoteCodemodeSandbox @earendil-works/pi-codemode/portable a Durable Object in a separately deployed Worker

See Usage on Node and Bun and Usage on Cloudflare Workers. A third sandbox, InlineCodemodeSandbox, runs scripts on the calling thread for other runtimes, or on Cloudflare when blocking the caller is acceptable.

Usage on Node and Bun

ts
import { CodemodeSandbox } from "@earendil-works/pi-codemode";

const sandbox = new CodemodeSandbox({
	timeoutMs: 60_000,
	tools: [
		{
			name: "read",
			execute: async (args, { signal }) => {
				const { path } = args as { path: string };
				return await readFile(path, "utf8");
			},
		},
	],
});

const result = await sandbox.execute(`
	const source = await tools.read({ path: "package.json" });
	text("bytes " + source.length);
	return JSON.parse(source).name;
`);

console.log(result.output); // [{ type: "text", text: "bytes 1234" }]
if (result.ok) console.log(result.value); // "@earendil-works/pi-codemode"
else console.error(result.error.kind, result.error.message);

await sandbox.close();

code is the body of an async function: return and top-level await work. Inside the script, with every sandbox:

  • tools.<name>(args) returns a promise. Arguments and results make a JSON round trip. A tool that throws rejects with an Error carrying the same message. Tool names are also exposed as identifiers: characters that are not valid in identifiers become _ (toCodemodeIdentifier), so my-tool is tools.my_tool as well as tools["my-tool"].
  • ALL_TOOLS lists { name, description } for every tool, with name as the identifier.
  • text(value) appends a text item to result.output; values other than strings are JSON-stringified. console.log/info/warn/error/debug append text items with console: true.
  • image(urlOrItem) appends an image item. It accepts a base64 data: URL, { image_url }, or an image block ({ type: "image", data, mimeType }, as MCP tools and @earendil-works/pi-ai produce). Remote URLs are rejected.
  • exit() ends the script successfully right away, keeping its output and store writes.
  • globals passed to the sandbox are called as top-level functions, for example a host helper image(ref). They behave like tools but are not recorded in result.calls. A name like models.classify puts the function on a frozen models object. With spread: true, execute receives all call arguments as an array instead of the first one, and signature replaces the declaration generated from the schemas.
  • store(key, value) and load(key) read and write JSON values synchronously. See Store.
  • Nothing else: no timers, fetch, process, require, modules, or WebAssembly. eval and Function work but only produce more code inside the same VM.

timeoutMs: Infinity disables the deadline; the script then runs until it settles or signal aborts it. A script that waits on a promise nothing can settle (no tool call pending, and the VM has no timers or I/O) fails right away instead of hanging.

memoryLimitBytes caps the VM's heap. Allocations beyond it fail inside the script as InternalError: out of memory.

Aborts, timeouts, and close() request worker termination without waiting for the worker to exit. On Bun, a built-in such as a long String.prototype.indexOf may not poll QuickJS's interrupt handler, so its worker can keep computing until the built-in returns. Stopping unrefs the worker after requesting termination, because terminate() refs it internally. This does not guarantee computation has stopped. On Bun 1.4.0, a pending terminate() can still delay host process exit despite unref(); cancellation results and close() do not wait for it.

Hosts that limit concurrency can track actual worker lifetime with onWorkerStarted(exited), called for each worker started by execute() or start(). Its promise resolves when that worker exits, not when the script result settles:

ts
const workers = new Set<Promise<void>>();
const sandbox = new CodemodeSandbox({
	onWorkerStarted(exited) {
		workers.add(exited);
		void exited.then(() => workers.delete(exited));
	},
});

Keep a concurrency slot occupied until exited resolves. close() does not wait for these promises.

To enforce an admission limit, pass acquireWorker(). It runs synchronously after WASM loading, immediately before each worker is created. Throw to reject the execution without starting a worker; otherwise return a release callback. The sandbox calls it once on actual worker exit or if the worker constructor fails. Share reservation state across sandbox instances, and do not release it when a script result settles.

Store

store/load let scripts keep values across executions. The sandbox does not persist anything itself: pass the current values as options.store, and a successful result reports what the script changed as result.storeWrites ({ set, delete }). Failed executions report no writes.

ts
const result = await sandbox.execute(`store("runs", (load("runs") ?? 0) + 1)`, { store: saved });
if (result.ok) {
	for (const key of result.storeWrites.delete) delete saved[key];
	Object.assign(saved, result.storeWrites.set);
}

load returns a copy, so mutating it does not change the store. Storing undefined deletes the key. A value may be at most MAX_STORE_VALUE_CHARS (256 Ki) characters of JSON and all values together at most MAX_STORE_TOTAL_CHARS (1 Mi); larger writes throw a RangeError inside the script.

Source format

parseCodemodeSource() accepts a script whose first line may be an options line:

js
// @options: {"max_output_tokens": 2000, "timeout_ms": 30000}
const source = await tools.read({ path: "package.json" });
text(JSON.parse(source).name);

Supported fields are max_output_tokens, a token budget for the output, and timeout_ms, a hard deadline. The sandbox does not act on them; the caller decides. The options line is replaced by an empty line, so line numbers in stack traces still match the input. Empty input, invalid JSON, unknown fields, an options line without code, or an options line on any line but the first throw CodemodeSourceError. CODEMODE_SOURCE_GRAMMAR is a Lark grammar for providers that support grammar-constrained tool input. Both are also available from the lightweight @earendil-works/pi-codemode/source entry.

Bundled Node and Bun hosts

By default CodemodeSandbox loads quickjs-wasi/quickjs.wasm from the installed package and starts the worker file that sits next to this package's module. Neither exists on disk when the host is bundled, so pass both:

ts
import { CodemodeSandbox, loadQuickJSWasm } from "@earendil-works/pi-codemode";

const sandbox = new CodemodeSandbox({
	tools,
	// Compiled once per path and cached.
	wasm: loadQuickJSWasm(pathToQuickJSWasm),
	// A file of your build containing `import "@earendil-works/pi-codemode/worker";`
	workerUrl: new URL("./codemode-worker.js", import.meta.url),
});

workerUrl accepts a URL or string. For a Bun compiled executable, include the worker as an
additional build entrypoint and pass its relative source path as a string, for example
"./src/codemode-worker.ts"; Bun resolves that form from its embedded module graph.

Usage on Cloudflare Workers

CodemodeSandbox needs Node or Bun. It runs each script in a worker thread and reads the QuickJS binary from disk. Cloudflare Workers have neither, and they cannot compile WebAssembly at runtime. Two other sandboxes have the same API, the same script environment, and the same CodemodeResult. Only where the VM runs differs:

Sandbox Where the VM runs Use it for
CodemodeSandbox a worker thread Node and Bun
RemoteCodemodeSandbox another isolate, usually a Durable Object in a separate Worker a Durable Object that runs an agent session
InlineCodemodeSandbox the calling thread anywhere else with WebAssembly, when blocking the caller is acceptable

Import the last two from @earendil-works/pi-codemode/portable, which has no Node imports. On Cloudflare, @earendil-works/pi-codemode/cloudflare adds the deployable Durable Object and the QuickJS module compiled by Wrangler.

Why a separate Worker

QuickJS runs synchronously, so a script that computes holds its thread until it awaits a tool. Measured on Cloudflare (October 2026), a script that computed for ten seconds delayed every other request to the session object by the full ten seconds in two placements:

  • inside the session's Durable Object
  • in a Dynamic Worker loaded by that object

In a Durable Object of a separately deployed Worker, the delay was about 50 ms. RemoteCodemodeSandbox therefore keeps the tools and call records in the session and sends only the VM to the sandbox Worker. Each tool call travels back to the session over Workers RPC.

Deploying the sandbox Worker

A complete setup is in examples/cloudflare. The sandbox Worker is one file:

ts
// sandbox/src/index.ts
export { CodemodeSandboxDurableObject as CodemodeSandbox } from "@earendil-works/pi-codemode/cloudflare";

export default {
	fetch: () => new Response("Not found", { status: 404 }),
};
jsonc
// sandbox/wrangler.jsonc
{
	"name": "pi-codemode-sandbox",
	"main": "src/index.ts",
	"compatibility_date": "2026-04-22",
	"workers_dev": false,
	"preview_urls": false,
	"migrations": [{ "tag": "v1", "new_sqlite_classes": ["CodemodeSandbox"] }],
	"limits": { "cpu_ms": 60000 }
}

Install @earendil-works/pi-codemode in the sandbox Worker's project and run npx wrangler deploy. Wrangler bundles QuickJS (about 640 KB) as a compiled WebAssembly module. The object has no routes and keeps no state, and it never uses its storage.

Using it from the agent's Worker

Bind the class with script_name. Binding a class exported from the agent's own Worker would bring back the blocking described above.

jsonc
// agent/wrangler.jsonc
"durable_objects": {
	"bindings": [
		{ "name": "CODEMODE_SANDBOX", "class_name": "CodemodeSandbox", "script_name": "pi-codemode-sandbox" }
	]
}

Then replace the sandbox. Tools, options other than the worker-specific wasm, workerUrl, acquireWorker, and onWorkerStarted, and results stay the same:

ts
import type { CodemodeSandboxDurableObject } from "@earendil-works/pi-codemode/cloudflare";
import { RemoteCodemodeSandbox } from "@earendil-works/pi-codemode/portable";

interface Env {
	CODEMODE_SANDBOX: DurableObjectNamespace<CodemodeSandboxDurableObject>;
}

// Before: new CodemodeSandbox({ tools, timeoutMs: 60_000 })
const sandbox = new RemoteCodemodeSandbox({
	tools,
	timeoutMs: 60_000,
	remote: () => env.CODEMODE_SANDBOX.getByName(sessionId),
});
const result = await sandbox.execute(code, { signal, store });

Import the sandbox from /portable in the agent's Worker. Importing /cloudflare would also bundle QuickJS there. remote is called once per execution and picks the Durable Object that runs it.

  • One object per session, getByName(sessionId), keeps a session's scripts together. Two scripts of the same session take turns on one thread.
  • One object per execution, getByName(crypto.randomUUID()), runs every script on its own.

Run both Workers locally with wrangler dev -c agent/wrangler.jsonc -c sandbox/wrangler.jsonc.

Limits on Cloudflare

  • Interrupt budget. While a script computes without calling a tool, no timer can run, and Date.now() does not advance. A script that never yields is limited only by the interrupt budget, not by timeoutMs. QuickJS polls an interrupt handler while it runs, and the sandbox counts the polls. Past the budget, the execution fails with kind: "timeout".
    • The default DEFAULT_INTERRUPT_BUDGET of 100,000 polls is about 2 seconds of a tight loop on an Apple M-series laptop and about 10 seconds on Cloudflare.
    • Keep the budget below the Worker's cpu_ms limit (30 seconds by default). Hitting that limit resets the object instead of failing one script.
  • Memory. Each VM may use up to 32 MiB by default. All VMs in one isolate share its 128 MB.
  • Changing the limits. The Durable Object caps every request at its own limits, whatever the client asks for. To change them, subclass it in the sandbox Worker:
ts
import { CodemodeSandboxDurableObject } from "@earendil-works/pi-codemode/cloudflare";

export class CodemodeSandbox extends CodemodeSandboxDurableObject {
	protected maxInterruptBudget = 200_000;
	protected maxMemoryLimitBytes = 64 * 1024 * 1024;
}
  • Aborts and timeouts. timeoutMs and signal end the execution in the caller right away, with kind: "timeout" or kind: "aborted". The remote VM stops at its next tool call. A remote VM that is computing at that moment keeps running until it calls a tool or uses up its budget.
  • Billing. Each execution is one Durable Object request. The sandbox object is billed for wall-clock duration while an execution runs, including time spent waiting on tools.

On the calling thread

When blocking the caller is acceptable, for example in a stateless Worker that serves one request, run the VM on the caller's thread instead. Nothing extra needs to be deployed:

ts
import { createInlineCodemodeSandbox } from "@earendil-works/pi-codemode/cloudflare";

const sandbox = createInlineCodemodeSandbox({ tools, timeoutMs: 60_000, interruptBudget: 50_000 });
const result = await sandbox.execute(code);

createInlineCodemodeSandbox() passes the QuickJS module Wrangler compiled to InlineCodemodeSandbox. With another bundler, import InlineCodemodeSandbox from /portable and pass your own compiled module as wasm.

  • Blocking. A script that computes blocks the caller until it awaits a tool or uses up its interruptBudget.
  • Stack. QuickJS's stack guard is reduced to INLINE_MAX_STACK_SIZE, so that it trips before the caller's smaller stack runs out.

Declarations for the model

Tools and globals can carry description, inputSchema, and outputSchema (JSON Schema). renderDeclarations() turns them into TypeScript declarations for a model-facing tool description:

ts
renderDeclarations({ tools: sandbox.tools, globals: sandbox.globals });
// declare const tools: {
//   /** Read a file */
//   read(args: {
//     path: string;
//   }): Promise<string>;
// };

Property descriptions become // comments. Constraints the type cannot express follow as tags, such as // @integer @minimum 1 @default 20; they are dropped first when a type exceeds the size limit. Schemas only shape the declarations; values are not validated against them. Local references (#/$defs/..., #/definitions/...) are expanded; recursive and remote references render as unknown.

Using with pi-agent-core

To give an Agent a codemode tool, expose its other tools to the sandbox and wrap execute() as an AgentTool:

text
/* Detailed source-code truncated for AI context efficiency. */

result.output items already have the shape of @earendil-works/pi-ai's TextContent and ImageContent. Calling tool.execute() directly skips the agent's beforeToolCall and afterToolCall hooks. To apply them to nested calls too, run each call through runToolCall() from @earendil-works/pi-agent-core, as the mcp-codemode example does. That example also rejects failed nested calls inside the script and combines codemode with MCP tools.

Deterministic runs

sandbox.start(code, options) runs a script so that a host can run it again after a restart and get the same behavior, for example to resume it from a durable log of its calls' results. execute() is unaffected.

The VM only runs when the host hands it something, and has no timers or I/O, so it is deterministic except for what it reads from its environment. start() fixes that:

  • Time: Date.now() reads startedAt, then the now of the result being processed, never going backwards. performance.now() counts from startedAt.
  • Randomness: Math.random() and the VM's other randomness come from seed.
  • Timezone: UTC, whatever the host's.
  • Garbage collection: WeakRef and FinalizationRegistry do not exist.
  • Result order: the host answers each call with a result number (1, 2, ...), and the VM applies results strictly in that order, whatever order a transport delivers them in.

The run declares its own tools and globals, and the host answers every call itself:

ts
const run = sandbox.start(code, {
	startedAt: Date.now(),
	seed: 7,
	tools: [{ name: "read", description: "Read a file" }],
	onStarted: (fingerprint) => {}, // what the VM is: engine versions, prelude hash, limits
	onCall: (call) => {
		// call.id is the VM's own number for the call, in the order the script made them.
		void readFile(call.args).then((text) => run.deliver(nextResult++, Date.now(), call.id, { ok: true, value: text }));
	},
	onIdle: (seq, issued) => {}, // after result `seq` (0: the first run), `issued` calls in total
});
const result = await run.result;

A run has no deadline unless timeoutMs is given. Result numbers must run 1, 2, ... without gaps. The same script, startedAt, seed, tools (with their jsName), globals and store, answered with the same results in the same order, makes the same calls with the same arguments and produces the same output on any VM with the same fingerprint. To end a run with an error the script cannot catch, abort its signal.

Results

execute() never rejects for script failures. result.error.kind is one of:

kind meaning
script the script threw or failed to parse; stack points at codemode.js:<line>
timeout the deadline expired or the interrupt budget ran out; VM shutdown was requested
aborted options.signal fired or close() was called; VM shutdown was requested
sandbox the VM or its transport failed, for example a wasm trap, a missing worker file, or a failed remote

result.output holds the text and image items in the order the script produced them, also for failed executions. The host keeps all of it until the script ends, so output is limited to MAX_OUTPUT_CHARS (16 Mi) characters of text and base64 image data and MAX_OUTPUT_ITEMS (100000) items. Past either limit the script fails with a RangeError, even if it catches the error. result.calls lists every tool call with status: "ok" | "error" | "cancelled". A call that is still running when the script returns (not awaited) is aborted through the tool's signal and reported as cancelled.

How it works

This section describes CodemodeSandbox. The other sandboxes share the same host side, prelude, and VM, and replace only the worker thread. See Usage on Cloudflare Workers.

Each execute() starts a worker thread (about 20 ms including VM creation) that instantiates a fresh QuickJS VM from the compiled wasm module. The VM is a separate wasm instance with its own linear memory. Its only imports are a WASI shim (clock, random, and stdout/stderr writes, which the worker discards) and one host-call entry point, so the script cannot reach the host except through the functions the worker registers.

The worker evaluates a prelude inside the VM that holds the single host bridge in a closure and builds tools, console, and globals on top of it. The script is compiled as an async function body.

Tool calls are relayed to the host thread as messages; the host runs the tool and posts the JSON result back. The host owns the deadline and the abort signal. When either fires, it sets a shared interrupt flag that the VM polls, then calls worker.terminate(). The flag is needed on Bun, where terminate() cannot stop a thread that is spinning in wasm (while (true) {} or while (true) await null).

The worker keeps script execution off the host thread: QuickJS runs synchronously, so a spinning script on the host thread would block its event loop.

Runtime notes

  • Node and Bun support memory limits, interrupts, and a catchable RangeError for deep recursion (QuickJS's stack guard is enabled; without it the wasm stack overflows and traps). Bun may delay worker exit during non-interruptible built-ins; results do not wait for exit.
  • Stack traces use QuickJS frames (at f (codemode.js:2:31)), prefixed with Name: message like V8. The wrapper prefix shares line 1 with the script, so line numbers are exact; column numbers on line 1 are shifted.
  • QuickJS is an interpreter. Glue code and filtering tool results are fast enough; heavy computation is slower than in V8.

Overview (`packages/coding-agent/README.md`)

New issues and PRs from new contributors are closed automatically. Maintainers review closed submissions daily. See CONTRIBUTING.md.

Pi

Pi is a minimal, extensible agent harness that you can make your own.

Adapt Pi to your workflows, not the other way around. Customize Pi with extensions, skills, prompt templates, and themes. Bundle them as Pi packages and share via npm or git.

Pi ships with powerful defaults but skips features like sub-agents and plan mode. Ask Pi to build what you want, or install a package that does it your way.

Use Pi interactively, automate it in print or JSON mode, control it over RPC, or build apps with the Pi TypeScript SDK. See OpenClaw for a real-world integration.

Getting started

Install the command-line interface:

bash
curl -fsSL https://pi.dev/install.sh | sh

On Windows:

shell
powershell -c "irm https://pi.dev/install.ps1 | iex"

The installer pins all dependencies and updates Pi with pi update. Alternatively, install directly with npm, which does not pin transitive dependencies:

bash
npm install --ignore-scripts @earendil-works/pi-coding-agent

Pi requires Node.js 22.19 or newer. The macOS, Linux, and Windows installers can install it if needed. Pi does not require dependency lifecycle scripts for a normal npm installation.

On macOS and Linux, Nix users can install the latest release with nix profile add github:earendil-works/pi/stable. See the quickstart for updating and pinning releases.

Start Pi in the directory where you want it to work:

bash
cd /path/to/project
pi

For a built-in AI provider, run /login inside Pi to connect a subscription or API key. Then give Pi a task.

See the documentation for full setup and usage instructions.

Share your OSS coding agent sessions

If you use Pi for open source work, please share your coding agent sessions.

Public OSS session data helps improve models, prompts, tools, and evaluations using real development workflows.

For the full explanation, see this post on X.

To publish sessions, use badlogic/pi-share-hf using a Hugging Face account and the Hugging Face CLI.

Development

Clone the repository, install its dependencies, and run Pi from source:

bash
git clone https://github.com/earendil-works/pi
cd pi
npm install --ignore-scripts
./pi-test.sh

pi-test.sh can be called from any directory and preserves the caller's working directory.

Before submitting changes, run:

bash
npm run check
./test.sh

Read CONTRIBUTING.md before opening an issue or pull request. It defines the contribution gate, issue quality bar, and required checks. Read AGENTS.md for repository-specific implementation, testing, dependency, and release rules.

License

MIT


Overview (`packages/durable/README.md`)

@earendil-works/pi-durable

Experimental. The API changes without notice between releases.

A durable agent harness. Conversations, model turns, tool calls, and your own state are committed to storage before anything is shown. If the process dies mid-turn, reopening the storage picks the work up where it stopped.

Built on @earendil-works/pi-ai for model access and @earendil-works/chord for document state.

Table of Contents

Installation

bash
npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord

Quick Start

typescript
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { AssistantEntry, createRegistry, Harness, MemoryStorage } from "@earendil-works/pi-durable";

const context = BACKGROUND_CONTEXT;

const models = createModels();
models.setProvider(openaiProvider()); // reads OPENAI_API_KEY

const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context);
const root = await harness.root(context, { agent: { model: { provider: "openai", modelId: "gpt-6-sol" } } });

const submission = await root.submit({ type: "input", content: "What is the capital of France?" }, context);
const settled = await submission.wait(context);
if (settled.status === "done" && settled.type === "input") {
	const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
	console.log(answer?.model?.[0]);
}
await harness.close(context);

What happened:

  • Harness.open() opens a Session over a storage backend. MemoryStorage keeps everything in memory.
  • root() returns the root conversation, creating it on first use with the given agent choices. A conversation is a transcript of immutable entries.
  • submit() durably admits your input and returns a Submission. A built-in generation task calls the model and appends the answer.
  • wait() resolves once the input is answered (done) or has failed (unanswered, with a reason).

Every async call takes a Chord Context, which carries cancellation and its current TelemetrySpan. BACKGROUND_CONTEXT never cancels and uses a NOOP span. The Harness owns the root adapter; adapters are never stored in a Context. Cancelling a wait only cancels that wait, never the work.

HarnessOptions.identity sets how model requests identify the app to providers (a pi-ai ClientIdentity), merged over DEFAULT_HARNESS_IDENTITY ({ attribution: PI_ATTRIBUTION }: OpenRouter, NVIDIA NIM, and OpenCode attribution headers name pi). It applies to generation, compaction, hooks, and tools, over the identity the host's Models was created with. Pass your app's attribution, for example { attribution: { name: "my-app", url: "https://my-app.example" } }, or { attribution: null } to send none.

HarnessOptions.telemetry accepts a host-owned TelemetryContext from @earendil-works/pi-telemetry, with optional propagation and metrics capabilities; omitted, it is a no-op. DURABLE_TELEMETRY_SCHEMA, exported from the package root, documents its typed spans, events, and metrics. Attributes contain IDs and control metadata, never messages, arguments, or results; IDs point to the transcript.

  • pi.durable.submit covers the public submit call and its commit. Each input starts a new root trace with a link to its caller's current span; a write is a child of its caller. Duplicate requestId preserves the original submission and carrier. Internal admissions, such as compaction writes, emit events on their phase span without a submit span.
  • pi.durable.phase covers one run or abort phase of every task, including its decision commit, never the next phase, a durable wait, or a restart. Its attributes identify the task, phase, attempt when present, resumed, and outcome. Tool phases also identify the tool and call. Nested tasks and background compactions inherit the creating phase; model requests receive that phase context. Hooks add no spans.
  • Failed phase outcomes set error status with a fixed category (task_faulted, task_failed, or task_orphaned) and the outcome's diagnostic message, truncated to 500 characters (the reason for an orphaned task). Diagnostics can contain caller content; no error detail or exception name is recorded. An aborted outcome keeps status ok, including interruption by close or cancellation.
  • Every onReport report also emits a pi.durable.report event on its current span. Without a span, the NOOP span drops the event; no root report span is created. Only type, optional fixed reason, extension, and task.id are recorded; no error payload is included. pi.durable.reports counts all reports by type only, including those without a span. Clock failures report without a phase context. The onReport callback is unchanged.
  • Committed entries and settled submissions emit pi.durable.entry and pi.durable.submission. Committed running phase changes emit pi.durable.checkpoint; a nested call answered from its stored result emits pi.durable.replay.
  • Propagation stores optional opaque trace carriers in submission and task JSON without a storage migration. Phase spans extract their task's stored carrier, or start at the adapter root when absent or unknown. TaskOptions.trace inherits the creator's carrier when absent, accepts an explicit carrier override, or uses "root" to start at the adapter root without storing a carrier. A run started for several queued inputs takes the first input's trace; its first invocation's phase spans link to the other inputs through immutable task links. A steer stays in the running trace and its submit span links to that run's stored carrier. A reopened task's first invocation has resumed: true in the same trace.
  • Metrics record pi.durable.phase.duration (milliseconds; task, phase, outcome) and pi.durable.tasks.active (live tasks; task name), without record IDs. Closing the Harness releases its active counts.

Concepts

  • Harness: one open storage plus the machinery that runs agents on it. All changes go through one line of atomic commits, and nothing is shown before its commit is stored.
  • Conversation: a transcript. root() creates the root conversation on first use; you can create more and fork them. A Conversation handle holds no state; compare handles by id.
  • Entry: one immutable transcript record, such as a user message (pi.user), a model response (pi.assistant), a tool result (pi.tool-result), a system prompt change (pi.system), a reset (pi.reset), or your own kind. The model sees the entries from the newest reset onward.
  • Commit: an atomic write. conversation.commit((tx) => ...) can append entries, edit documents, and create tasks together; either all of it is stored or none of it.
  • Document: typed JSON state stored next to the transcript and changed in commits. Built-in ones hold each conversation's agent choices (pi.agent), provider-facing session identity (pi.provider), running generation and tools (pi.live), queued submissions (pi.inbox), and spend (pi.usage).
  • Task: a durable state machine that saves a checkpoint at every step, so a restarted process continues from the last one. Every task has an owner: its conversation, or another task. The Harness runs answers as built-in tasks: pi.generation calls the model and owns the pi.tool tasks of its tool calls, waits for them, and hands the run to the next generation.
  • Submission: something you hand to a conversation, either user input or an entry to write, which you can wait for.
  • Turn and run: a turn is one model response and its tool calls; a run is the turns from an input to its final answer. A conversation is busy while a run is going.
  • Extension: a named bundle of tools, system prompt sections, hooks, wrappers, and tasks.
  • Registry: the extensions this process installed. It can change while the Harness runs; new work uses the new state.
  • Agent: what a conversation runs with: model, thinking level, selected extensions, tools, instructions, and working directory. Stored per conversation as names in pi.agent, resolved against the registry at each use.

One answered input, as entries and tasks:

text
submit(input) → pi.user
  pi.generation → pi.system (only if the prompt or tools changed), pi.assistant (tool calls)
    pi.tool × n → pi.tool-result × n   (owned by the generation, which waits for them)
  pi.generation → pi.assistant (answer) → submission done

Persist and Resume

Use SQLite or JSONL storage to keep conversations across restarts:

typescript
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";

const harness = await Harness.open(await openNodeSqliteStorage("./session.sqlite"), { models, registry }, context);
const root = await harness.root(context); // the same root as last time
harness.resume(); // continue any run the last process left unfinished

Work interrupted by a crash or close stays pending. resume() starts the task scheduler; submitting or waiting starts it too. Each conversation has its own persisted UUIDv7 in pi.provider, forwarded to pi-ai as sessionId for provider prompt-cache and session affinity. It survives reopen, retries, reset, compaction, and model changes; a child or fork receives a fresh identity. A legacy conversation receives and persists one before its first generation or compaction request.

A retried submission with the same requestId returns the existing submission instead of submitting twice:

typescript
const submission = await root.submit({ type: "input", content: "Hello", requestId: "greeting-1" }, context);
// After a restart: the same request ID finds the same submission.
const again = await root.submit({ type: "input", content: "Hello", requestId: "greeting-1" }, context);
// again.id === submission.id

harness.submission(id) reacquires a submission by ID, for example to wait for it after a restart.

Sleeping and Waking Up

A task waits for time by ending its phase asleep. The sleep is stored, so the task holds no invocation meanwhile, survives restarts, and resumes at the checkpoint once the Harness clock reaches until:

typescript
const Reminder = defineTask<{ text: string; at: number }, { phase: "wait" } | { phase: "deliver" }, null>({
	name: "app.reminder",
	version: 1,
	initial: () => ({ phase: "wait" }),
	phases: {
		// Resolves once the sleep is stored; the phase ends there.
		wait: (task, runtime, context) => runtime.sleepUntil(task.input.at, { phase: "deliver" }, context),
		deliver: async (task, runtime, context) => {
			await notify(task.input.text);
			await runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: null } }), context);
		},
	},
	abort: (_task, runtime, context) =>
		runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context),
});

There is no sleep in the middle of a phase: a phase reruns from its top after a restart, so work after the wait goes in the next phase, and what it needs goes in the checkpoint. { status: "sleeping", until, checkpoint } returned from runtime.commit() is the same sleep, committed together with other writes. Retry backoffs and deferred-response polls of the built-in tasks sleep the same way.

The timers of the Session, the scheduler, and its tasks go through setTimer (default setTimeout), and their times through now (default Date.now); pass both to run a Harness on a fake clock. The Node.js environment, file watcher, and image worker keep their own timers. Sleep deadlines are stored on now, so every process that opens one storage must use the same clock. While scheduling runs, a timer resumes sleeping tasks when they are due and keeps a Node.js process alive meanwhile.

A host whose process can be evicted, such as a Cloudflare Durable Object, wakes it itself. With onWake, the Harness arms no timer for sleeps; it tells the host what harness.wake() says whenever that changes: active while some task can run now or is running, and at, the earliest sleep. The host keeps its alarm armed and calls resume() when it fires:

text
/* Detailed source-code truncated for AI context efficiency. */

The default setTimer declines the timers that only free memory in Workers, so no pending timer keeps the object from hibernating or being evicted; memory is then freed at later commits.

Failures and Recovery

Crash loops. Work that deterministically crashes its process, by exhausting memory or CPU before its next checkpoint, would otherwise be resumed and crash again forever. Open counts each task a crash, eviction, or reset cut off; a close is not counted, and a new state the task commits starts the count over. Once the count reaches settings.restartLimit (default 5; Infinity never parks), the task is parked: inspect() and the task graph show it as blocked with reason restart_limit, wake() does not count it as active, and onReport gets it once as task_parked. Every task running when a process dies is counted, so show parked tasks and offer harness.retryTask(id), which runs one again. abortTask() still runs its abort handler; an abort handler cut off that many times ends the task faulted.

Reports. onReport(error, info) says what happened in info.type: session_failed for the error that failed the Harness (a Storage error, often a platform reset); task_faulted, task_parked, migration_failed, task_outdated, or task_error for a task, with info.task (its id, kind, and conversationId) and the extension that installed it in info.extension; extension_error for hooks, sections, wrappers, and tools, with the extension's name; host_error for a host callback, named in info.callback; and listener_error. A host can feed task_faulted reports of one kind to its own breaker.

Queued inputs after a failed run. A run that fails with a model error leaves the inputs queued behind it waiting until the next input arrives. settings.afterFailedRun changes that: continue starts the next run with them, as after an answer, and withdraw settles them unanswered with reason run_failed.

Blocked tasks. Tasks blocked on a missing or unmigratable definition show their reason in the task graph too, and a registry change that unblocks them makes onWake report the Session active, so a host need not poll them.

Extensions

Code the Harness runs, other than its built-in tasks, comes in named extensions installed in a registry your process owns:

typescript
import { createRegistry, defineExtension, defineTool, hook, section, ToolTask } from "@earendil-works/pi-durable";
import { CodingTools } from "@earendil-works/pi-durable/tools";

const Coding = defineExtension({
	name: "coding",
	sections: [section("preamble", () => "You are a concise coding assistant.", { tag: false })],
	hooks: [hook(ToolTask, { beforeTool: (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined) })],
});

const registry = createRegistry();
registry.install(CodingTools);
registry.install(Coding);

An extension may bring tools, sections, hooks, wraps (decorators of a tool or section by name), and tasks. By default every conversation selects every installed extension, in install order. Nothing in the registry is stored; conversations store extension names.

Tools

@earendil-works/pi-durable/tools provides read, write, edit, and bash, and the CodingTools extension with all four. They touch files and processes only through the call's environment (see Environment).

read returns an image file as one image block, which programs calling it get as an ImageContent. Limits are the model's inputLimits.images.resize, by default 2000x2000 pixels and 4.5 MB of base64. Without an image processor it sends PNG, JPEG, GIF, and WebP files within them (dimensions read from the file's header) as they are, without decoding them, and refuses larger ones and other formats. With one, it decodes every image, turns it upright by its EXIF orientation, converts BMP to PNG, and shrinks it to fit. The Photon-based processor runs on WebAssembly and is only loaded when you import it:

typescript
import { createNodePhotonImages } from "@earendil-works/pi-durable/images/node"; // or /images/cloudflare, or createPhotonImages(wasm) from /images
import { createCodingTools } from "@earendil-works/pi-durable/tools";

registry.install(createCodingTools({ images: await createNodePhotonImages() }));

A model without image input sees a placeholder instead of the image, and read says so in a diagnostic. /images/node prepares images in a worker thread, so a large photo (a second or two of work) never blocks the event loop; the worker exits after 30 s without images, freeing Photon's WebAssembly memory (createNodePhotonImages({ worker: false, idleMs })). Elsewhere, createPhotonImages() and /images/cloudflare work on the calling thread, and decoding takes the image's full size in WebAssembly memory, which does not shrink again; leave the processor out on memory-constrained hosts such as Cloudflare Workers unless images stay small.

Define your own tool with a TypeBox schema. defineTool() types args from parameters, which the Harness validates before execute(). api.output() streams running output, which becomes the result when execute() returns no output:

typescript
import { Type } from "@earendil-works/pi-ai";

const count = defineTool({
	name: "count",
	description: "Count from 1 to n",
	parameters: Type.Object({ n: Type.Number() }),
	execute: async (args, api) => {
		for (let i = 1; i <= args.n; i++) api.output(`${i}\n`);
		return {};
	},
});
registry.install(defineExtension({ name: "count", tools: [count] }));

Each call runs as its own durable task. Its intent is committed before execute() runs. If the process dies mid-call, the tool reruns on reopen only when it is declared replay: "safe"; otherwise the model gets an interrupted error result with the output committed so far. Throwing from execute() gives the model an error result. A result can also return usage, which is added to the conversation's usage. It can also return control: { terminate: true }: when every result of the round asks for it, the run ends without another model request.

A result has three channels: output for the model, structuredOutput for programs that call the tool (see Calling tools from tools), and details for UIs. A tool that declares structuredOutputSchema returns a matching structuredOutput, which the Harness validates. Programs calling a tool without one get its bounded output: one text item as a string, one image as its ImageContent, nothing as "", and anything else as the content list, for errors too. structuredOutput is always the tool's own value: a schema tool's error result keeps the value it returned, and the text of one without is in a tool_error diagnostic, as a thrown error's message is. The transcript stores only output and details:

typescript
const lines = defineTool({
	name: "lines",
	description: "Count the lines of a file",
	parameters: Type.Object({ path: Type.String() }),
	structuredOutputSchema: Type.Object({ lines: Type.Number() }),
	execute: async (args, api, context) => {
		const text = await readText(api, args.path, context);
		const count = text.split("\n").length;
		return { output: [{ type: "text", text: `${count} lines` }], structuredOutput: { lines: count } };
	},
});

A missing, undeclared, or invalid structuredOutput turns a nested call's result into an error result with an invalid_structured_output diagnostic; for a call the model made, which never sees it, it is dropped and reported through onReport. An error result may omit it, and programs then find its text in a tool_error diagnostic; error results the Harness writes itself (blocked, invalid, interrupted, aborted) carry none, and their diagnostics say what went wrong. An afterTool hook that redacts a schema tool's output must redact its structuredOutput too. The built-in bash returns { output, truncated, fullOutputPath?, exitCode }, where output is the retained tail the model sees, read with api.retainedOutput(), and answers a nonzero exit with an error result that still carries it.

A later extension's tool with the same name replaces an earlier one where both are selected, and wrapTool() decorates whichever tool won:

typescript
const Venv = defineExtension({ name: "venv", tools: [createBashTool({ commandPrefix: "source .venv/bin/activate" })] });
const Timing = defineExtension({
	name: "timing",
	wraps: [wrapTool(createBashTool(), (bash) => ({ ...bash, execute: (args, api, ctx) => timed(() => bash.execute(args, api, ctx)) }))],
});

Calling tools from tools

A tool calls another tool with api.executeTool(), as a code mode script or an MCP bridge does. The nested call is its own tool task, owned by the calling one: it is validated, runs the ToolTask hooks, keeps its own output limits and replay policy, and returns its result to the caller instead of the transcript:

typescript
const testsPass = defineTool({
	name: "tests_pass",
	description: "Run the test suite and say whether it passed",
	parameters: Type.Object({}),
	execute: async (_args, api, context) => {
		const run = await api.executeTool("bash", { command: "npm test" }, context, { progress: false });
		// Absent when bash did not finish, for example on a timeout.
		const exitCode = (run.structuredOutput as { exitCode: number } | undefined)?.exitCode;
		return { output: [{ type: "text", text: exitCode === 0 ? "passed" : "failed" }] };
	},
});
  • The caller gets the nested call's taskId, structuredOutput, details, diagnostics, usage, and isError, not the output meant for the model. A blocked, invalid, failed, or aborted nested call returns an isError result. executeTool() itself rejects once execute() has returned, when the caller is aborted, or when the Harness closes; cancelling its context stops only the wait.
  • Hooks see a nested call's call.parent, so a guard can treat calls from scripts differently.
  • While the caller runs, its nested calls show in docs["pi.live"].nestedTools, with the arguments they run with and a summary (error, duration, usage) once done, and as tool events with parentToolCallId and parentTaskId. progress: false keeps a nested call's running output out of pi.live. Before the caller settles, nested calls it left running are aborted, also those an earlier attempt made. The caller's result message does not list its nested calls; their task records keep them.
  • Each nested call has a key, its position among the caller's nested calls unless the caller passes { key }. A replay-safe caller that reruns after a crash and calls in the same order, or passes the same keys, gets the nested calls it already made back, finished or still running, instead of starting them again. A nested call the crash interrupted before its result was committed follows its own replay policy, so a replay-safe tool with external effects still needs its own idempotency. A caller that is not replay-safe never reruns, so after a restart its unfinished nested calls are abandoned, with everything they own, before they run again.
  • A nested call's usage is counted under its own tool; passing it on in the caller's result counts it twice.
  • Nested results are not kept: they live in the caller's task documents (NestedCallDoc, one per nested call, keyed by its key, holding { taskId, result? }) only until the caller settles. The nested call's task record keeps only a small receipt. A stored result is exactly what executeTool() returns, without the model's output, so an image or a bash tail is stored once.
  • Nothing bounds how deeply calls nest; a tool with the default callers may even call itself. Restrict callers where that matters (below).

Who may call a tool

callers on a tool says who may call it: the model, other tools through executeTool(), or both, the default. A conversation's modelTools narrows which of its tools the model is offered, without taking them away from tools:

typescript
const codemode = defineTool({ name: "codemode", callers: ["model"], ... }); // scripts cannot start scripts
const search = defineTool({ name: "mcp__github__search", callers: ["tools"], ... }); // never offered to the model

// The model is offered only codemode; its scripts call read, bash, and the MCP tools.
await root.configure({ modelTools: [codemode] }, context);
// The model is offered everything but bash, which scripts may still call.
await root.configure({ modelTools: { remove: [bash] } }, context);

conversation.agent() resolves both lists: tools, offered to the model, and callable, which nested calls resolve among. tools still decides what is enabled at all; modelTools cannot add to it.

A deferred: true tool is not offered to the model until it is loaded, though other tools can call it any time. The tool_search tool from @earendil-works/pi-durable/tool-search (createToolSearchExtension()) lets the model search the deferred tools and loads the matches with control.addTools, which records them in modelTools; loaded tools stay offered on that branch, also after a compaction, and the codemode section leaves deferred tools out. Use it for large tool sets, such as an MCP server's.

Tasks a restart abandons

A task that its creator awaits only in memory, and that the creator never resumes after a restart, can be created with abandonOnRestart: true. When a later Harness starts scheduling and finds it still live, it is aborted with abortReason: "restart", together with everything it owns, before any of it runs again. Nested calls of tools that are not replay-safe get this automatically, and so do the child tasks such a tool creates with api.createTask(). A task abandoned this way whose definition is missing waits until its extension is installed, so its abort handler still cleans up. Its caller, and the conversation, wait with it; harness.inspect() shows it as blocked. Aborting it then orphans it, as any abort does.

Code Mode

@earendil-works/pi-durable/codemode adds a codemode tool: the model writes JavaScript that calls the conversation's tools as await tools.<name>(args), in a QuickJS sandbox (@earendil-works/pi-codemode). Each call is a nested tool call, and only the script's output reaches the model. The script survives a restart: it resumes where it was, without running its finished calls again.

typescript
import { CodemodeSandbox } from "@earendil-works/pi-codemode";
import { createCodemodeExtension } from "@earendil-works/pi-durable/codemode";

registry.install(createCodemodeExtension({ sandbox: new CodemodeSandbox({ memoryLimitBytes: 256 * 1024 * 1024 }) }));

The extension also adds a codemode prompt section listing the tools scripts can call, as TypeScript declarations within a token budget; scripts find the others with searchTools(), describeTool(), and describeNamespace(). Group tools by name prefix with namespaces: [{ name, description, prefix }], for example an MCP server's mcp__github__. The result reads like the coding agent's: a header, the script's output, and on failure the calls already made; text past max_output_tokens is cut, with the full text in a temp file. Scripts also get models.*: the model catalog, models.classify(), and models.generateImages(), which run as nested calls of internal tools (models: false turns them off). On Cloudflare, pass a RemoteCodemodeSandbox instead. Closing the sandbox while a script runs (for example when shutting down) is not an outcome: the script resumes with the next process's sandbox. A failed nested call rejects in the script with its error text, unless the tool declares a schema and returned its own value with the error: tools.bash resolves with { output, exitCode, ... } also for a nonzero exit, so scripts check exitCode. store(key, value) and load(key) keep JSON values per conversation; a fork sees them as they were at its entry.

How it survives a restart: the sandbox runs the script deterministically (a virtual clock, seeded randomness, UTC), and the tool writes each result the script gets to a journal before handing it over. After a restart, the tool runs the script again and hands it the journaled results in the same order, so it makes the same calls with the same arguments; these reattach to their nested calls by key, and then the script continues. A rerun ends the call with an error instead when the script takes another path than before, when the sandbox's fingerprint or the code mode version changed, or when its timeout_ms passed meanwhile. A nested call running at the restart follows its own replay policy.

System Prompt

The system prompt is built from the selected extensions' sections, rendered in order before each request. A section sees the resolved agent, the environment built for the request, and committed documents:

typescript
section("cwd", (input) => input.env?.cwd); // rendered as <cwd>\n...\n</cwd>; undefined omits it

A conversation's instructions render last, as the section instructions. Sections and tool changes are stored as positional system entries in the transcript. Only what changed is sent again, which keeps provider prompt caches warm. A section that returns something different every time, such as the current time, defeats that.

Per-Conversation Agent

Each conversation stores what it runs with in its pi.agent document. configure() changes it in one commit; unset fields follow the host:

typescript
await root.configure(
	{
		model: { provider: "openai", modelId: "gpt-6-sol" },
		thinkingLevel: "high",
		extensions: { remove: [Coding] }, // edits the host default; an array selects exactly these, in order
		tools: [readTool, bashTool], // an array enables exactly these; { remove: [...] } drops some
		modelTools: [readTool], // of those, offer the model only read; tools may still call bash
		instructions: "Only read; never edit files.",
		cwd: "/work/repo",
	},
	context,
);
await root.configure({ tools: null }, context); // null clears a field back to the host default
const agent = await root.agent(context); // resolved: model, extensions, tools, sections, cwd

Extensions and tools are passed as objects and stored by name, so a stored name outlives its code: after an extension is uninstalled, conversations that select it just stop getting it until it is installed again. createConversation(), fork(), and root() take the same change as agent. A task-owned conversation, such as a subagent's, starts as a copy of its owner's conversation's agent. A fork starts with the agent its parent had at the fork entry. The model, prompt, and offered tools of a request are fixed when it is prepared; a change applies from the next request. Tool calls and hooks use the agent as their task phase resolves it, and the environment is built from the current cwd at each use, so a cwd or extension change can reach calls the model already made.

Settings

Run policy shared by every conversation is passed as settings. It is read at every use and never stored, so getters make it live, for example backed by a settings file:

typescript
const harness = await Harness.open(storage, {
	models,
	registry,
	settings: {
		extensions: [CodingTools, Coding], // default selection; absent: every installed extension
		stream: { timeoutMs: 120_000 },
		retry: { maxRetries: 3 },
		compaction: { reserveTokens: 16384 },
		progress: { partialIntervalMs: 100, outputIntervalMs: 100 },
		toolExecution: "parallel",
		contextRetentionMs: 600_000, // idle conversations keep their context in memory this long (checked lazily where background timers are declined, as in Workers)
		documentRetentionMs: 600_000, // loaded documents nothing observes stay in memory this long after their last use; observed ones always
		get followUpMode() {
			return userSettings.followUpMode;
		},
	},
}, context);

Environment

env builds the execution environment for each tool call, section rendering, and runtime.env(). It receives the conversation's ID, its agent cwd, and committed reads, so one function serves a directory per conversation or a container per conversation:

typescript
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";

const harness = await Harness.open(storage, {
	models,
	registry,
	env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? process.cwd() }),
}, context);

A throw from env becomes the call's error result. Without an environment, the built-in tools fail with an error result. A fresh environment object per call is fine: edit and write serialize changes to one file by the environment's id and path. A custom ExecutionEnv sets id so that equal ids see the same files at the same paths, for example one id per container.

Hosts can use the environment directly too, for example to show a project's files. openBinaryReader() reads byte ranges of one opened file, openDirReader() pages a directory, and exec() with an argv array runs a program without a shell, reporting which stream each output chunk came from:

typescript
const status = { stdout: "", stderr: "" };
await env.exec(["git", "status", "--porcelain=v2", "-z"], {
	onOutput: (text, _context, { stream }) => {
		status[stream] += text;
	},
}, context);

Abort the context to stop one call; cleanup() is for shutting the environment down. A custom environment can check itself with registerEnvConformance() from @earendil-works/pi-durable/testing, like storage below.

Some environments also implement the optional listFiles(): the files and directories below a directory at any depth, as fd lists them, skipping what git ignores and stopping at bounds with truncated. NodeExecutionEnv does not; pi-env's RemoteExecutionEnv and LocalExecutionEnv do. Callers fall back to their own walk when the method is missing or fails with not_supported:

typescript
const listing = await env.listFiles?.(".", { exclude: ["node_modules"], maxEntries: 50_000 }, context);

Reload

Installing an extension with an installed name replaces it in place, in one step:

typescript
registry.install(await loadCodingExtension()); // same name "coding": replaces the installed one

registry.uninstall(extension) removes the installed extension with that name, whichever object it is.

Work that already started keeps the code it took: a running tool call finishes under its old implementation, and each task phase resolves hooks and the agent once, from the registry at the phase's start. The next phase, request, or call uses the new code. After a restart, install the same extensions again; pending tasks of an extension's tasks resume once it is installed.

Watching a Conversation

Everything a UI needs is committed state. viewState() returns the conversation's structural view as a read-only Chord state, updated after every commit that touches it:

typescript
const view = await root.viewState(context);
view.subscribe((value) => {
	// value.entries: the active transcript
	// value.docs["pi.live"]: the running generation (streamed partial, retry, deferred) and tool calls (output, details)
	// value.docs["pi.inbox"], value.docs["pi.usage"], value.docs["pi.agent"], value.docs["pi.provider"]
	render(value);
});
// later: view.dispose();

watch() delivers the same view with the exact Chord operations of each commit, one callback at a time:

typescript
const watch = await root.watch(context);
render(watch.value); // the state at attachment
watch.start(async (value, ops) => {
	await send(ops); // for example to a remote client that applies them
});
// later: await watch.stop();

A slow watch keeps at most 100 undelivered frames. After that, the pending frames are replaced by one frame holding the whole newest view. A client that joins late or reconnects starts from the current view; nothing is replayed.

Partial answers and tool output are committed at most every 100 ms by default, so a crash loses at most that window. settings.progress changes the intervals; a host whose storage is remote can commit less often, for example { partialIntervalMs: 500, outputIntervalMs: 500 }.

Busy Conversations

A conversation is busy while a run is working on an input. Submitting to a busy conversation queues the submission in the conversation's inbox, docs["pi.inbox"] in the view:

typescript
await root.submit({ type: "input", content: "Also run the tests" }, context); // follow-up (default)
await root.submit({ type: "input", content: "Use pnpm, not npm", whenBusy: "steer" }, context);
await root.submit({ type: "input", content: "Only if idle", whenBusy: "reject" }, context); // throws ConversationBusy
await root.submit({ type: "write", entry: { kind: "app.note", data: "user opened a file" } }, context);
  • Steers are placed after the current tool round and join the running work.
  • Follow-ups are placed when the run answers, and start the next run.
  • Writes append an entry without asking the model anything. A write of only kind and data changes nothing the model sees, so it is appended at once, even mid-run, and is never queued: a chat message shows in the transcript as soon as it is sent. It can therefore appear before items submitted earlier that are still queued, except behind a queued reset, where it waits so the reset does not hide it. A write with model, head, or edits waits for the next boundary like a steer.
  • await submission.abort(context) withdraws a queued submission.
  • The settings steeringMode: "all" and followUpMode: "all" place every queued item at once instead of one per turn.

If a run fails, queued items stay in the inbox until the next input or queued write places them, oldest first.

An input is placed as a pi.user entry. To show it as something else, give it an entry with your kind and data; the model still sees content as the user message:

typescript
await root.submit(
	{
		type: "input",
		content: "ann: can you check the build?",
		entry: { kind: "chat.mention", data: { author: "ann", text: "can you check the build?" } },
	},
	context,
);
// placed as { kind: "chat.mention", data: { author: "ann", ... }, model: [{ role: "user", content: "ann: can you check the build?" }] }

It behaves like any other input: it starts a run, steers, follows up, or rejects, and is answered. While queued, its inbox item carries the entry. The kind must not start with pi..

Reset and Handoff

reset() starts a new context. The model no longer sees older entries, but they stay in storage:

typescript
await root.reset(undefined, context);                                  // start from nothing
await root.reset("We were fixing the flaky login test. Continue.", context); // start from a handoff note

While busy, the reset is queued like a write. When it is placed during a tool round, the current run ends. A tool can request the same with control: { handoff: "..." }.

Compaction

Compaction shrinks what the model sees: it summarizes older entries and appends a pi.compaction entry that holds the summary and heads the first entry it keeps. Older entries stay in storage.

typescript
const id = await root.compact("Keep the failing test names", context); // manual, with optional instructions
const { outcome } = (await harness.waitForTask(id, context)).state;
if (outcome.status === "completed" && outcome.result.submissionId !== undefined) {
	const placed = await (await harness.submission(outcome.result.submissionId, context))!.wait(context);
	console.log(placed.status); // "done", or "unanswered" with reason "stale"
}

The conversation keeps working while the summary is made. The summary is placed at once when the conversation is idle, otherwise at the next turn boundary. Esc (abort()) cancels a manual compaction.

Generation also compacts on its own, controlled by the settings:

typescript
settings: {
	compaction: {
		enabled: true, // automatic compaction; manual compact() always works
		reserveTokens: 16384, // above contextWindow - reserveTokens, the next request waits for a compaction
		keepRecentTokens: 20000, // roughly how much recent context stays verbatim
		backgroundTokens: 32768, // this far below that, a compaction starts in the background; 0 disables it
		modelOverrides: {
			// exact "provider/modelId"; each field set here replaces the one above for that model
			"local/small-32k": { reserveTokens: 4096, keepRecentTokens: 8000, backgroundTokens: 4096 },
		},
	},
}

The fixed token counts rarely suit a 32k and a 1M context window alike; modelOverrides lets one harness serve both. The conversation's current model picks the entry, at the threshold check, after a context overflow, and when a compaction selects what to summarize.

When a provider rejects a request because the context is too long, generation compacts and retries once. A summary that would cut before the start of the current context settles as stale when it is placed, so when several are in flight, the furthest cut stays in effect. Summarization spend counts in pi.usage. A beforeCompact hook on CompactionTask can decline or supply its own summary.

When a compaction that a generation waits for at the blocking threshold fails, the generation fails too, without sending a request: its inputs settle unanswered with model_error and detail Compaction failed: <error>. One that finds nothing to compact or is declined lets the request go ahead.

Running compactions are listed in docs["pi.live"].compactions with their reason, attempt, and retry backoff. The newest failure stays in docs["pi.live"].compactionFailure (task, reason, error) until a compaction produces a summary. The agent events add compaction_start and compaction_end (with error when the compaction failed), and compactions and compactionFailure fields in the snapshot.

Agent Events (Experimental)

For consumers that want coding-agent style events (message_start, message_update, tool_execution_start, ...) instead of structural state:

typescript
import { watchEvents } from "@earendil-works/pi-durable";

const stream = await watchEvents(harness, root.id, context);
initialize(stream.snapshot); // entries, run, in-flight generation, tools, compactions, inbox, agent, usage
stream.start(async (events) => {
	for (const event of events) console.log(JSON.stringify(event));
});

Events are derived from commits, one batch per commit, and apply on top of the snapshot. Message and tool updates carry deltas: text and thinking appends, appended tool-call argument text, and output trims and appends. When a consumer falls more than 100 batches behind, it receives a fresh snapshot event instead. See test/examples/19-json.ts for the full stream of one run.

Hooks

Hooks let extensions observe or adjust the built-in tasks, in the conversations that select them:

typescript
import { GenerationTask, hook, ToolTask } from "@earendil-works/pi-durable";

const Guard = defineExtension({
	name: "guard",
	hooks: [
		hook(ToolTask, { beforeTool: (call) => (call.name === "bash" ? { block: "bash is disabled here" } : undefined) }),
		hook(GenerationTask, { onYield: (answer) => (needsMoreWork(answer) ? { continue: "Keep going." } : undefined) }),
	],
});
  • Generation: beforeRequest (replace the messages of one request), afterResponse, onYield (continue the run with another user message), and afterTools (runs once a round's tools are done).
  • Tools: beforeTool (block or rewrite arguments) and afterTool (replace the result).

To limit a hook to some conversations, select its extension only there, for example with configure({ extensions: { add: [Guard] } }).

More Conversations and Forks

typescript
const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
const fork = await root.fork(entryId, { ownership: { kind: "ownerless" } }, context);

A fork sees its parent's entries up to entryId and continues independently. It keeps the parent's agent as of that entry but receives a fresh provider session identity. Both take agent and init, applied in the creating commit.

Abort and Subagents

await root.abort(context) stops a conversation: queued inputs are withdrawn (queued writes stay), every task of its current work is aborted, and the call resolves once the conversation is idle.

A conversation can be owned by a task. A subagent tool creates its child inside api.commit() with ownership: { kind: "task", taskId: api.taskId }, then drives it through api.conversation(id):

typescript
const Subagent: Extension = defineExtension({
	name: "subagent",
	tools: [
		defineTool({
			name: "subagent",
			description: "Delegate a self-contained task to a subagent and get its answer back.",
			parameters: Type.Object({ task: Type.String() }),
			replay: "safe", // a rerun after a crash finds the same child and submission
			execute: async (args, api, context) => {
				const child = await api.commit(async (tx) => {
					// The ownership index remembers the child, so a rerun reuses it.
					const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
					if (existing !== undefined) return existing.id;
					// Starts as a copy of this conversation's agent: model, extensions, tools, cwd.
					const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
					// A cheaper model, and no subagents of its own.
					await configure(tx, created.id, { model: haiku, extensions: { remove: [Subagent] } });
					return created.id;
				}, context);
				await api.details({ conversationId: child }, context); // lets a UI attach to the child
				const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` } as const;
				const settled = await (await (await api.conversation(child, context))!.submit(request, context)).wait(context);
				return { output: [{ type: "text", text: settled.status }] };
			},
		}),
	],
});

Owned work belongs to its owner:

  • Aborting the call aborts the child. So does the call failing: execute() throwing, or a crash that interrupts a call that is not replay-safe.
  • The parent is idle only once the child is.
  • A task created with { background: true } is a boundary: work it owns survives the parent's abort and does not keep the parent busy. root.abort(context, { background: true }) aborts it too.

The examples show both patterns as product code:

  • 22-subagent-foreground.ts: the tool above, returning the child's answer. The UI finds the child through the call's details and prints the child's events indented under the call.
  • 23-subagent-background.ts: persistent subagents behind one subagent tool that spawns, messages (steer or follow-up), waits for, stops, and lists them. Each child is owned by a background anchor task, so the parent's Esc and idle waits never reach it. Each message is delivered by a background reporter task that posts the answer back to the parent as a follow-up input once it arrives; request IDs keep a restart from sending a message or a report twice.

Child Tasks

A task can own child tasks, created with ownership: { kind: "task", taskId }, and wait for them by committing a waiting state:

typescript
pay: async (task, runtime, context) => {
	await runtime.commit(async (tx) => {
		const payments = [];
		for (const card of task.input.cards) {
			payments.push(await tx.createTask(Payment, { card }, { ownership: { kind: "task", taskId: task.id } }));
		}
		// Resume in `decide` once every payment is done; the first failure aborts the rest.
		return { status: "waiting", checkpoint: { phase: "decide", payments }, on: payments, policy: "failFast" };
	}, context);
},
decide: async (task, runtime, context) => {
	const outcomes = await runtime.outcomes(task.state.checkpoint.payments, context);
	// ...commit the checkout's own outcome
},
  • Waiting: the task runs no code while it waits. With allSettled it resumes once every task in on is done; with failFast the first failed child also aborts the others. on may name other tasks too, with allSettled.
  • Finishing: a task that finishes while work it owns is still running is completing: its outcome is decided, but it becomes terminal, and waitForTask() returns, only once that work is done. A failed or aborted outcome aborts that work first.
  • Aborting: abort runs bottom-up. Aborting a task aborts the work it owns first, and its own abort handler starts only once that work is done, so each task undoes its own effects.

24-child-tasks.ts runs a checkout with four payments: a declined card, a cancelled checkout, and a restart while the payments run.

Task Graph

harness.taskGraph(context) shows every live task of the Session as one Chord state, for a task panel or debugging. Each node has its owner edge (owner task, or none for a task its conversation owns), its status, whether it is background or abort-marked, and the conversations it owns. harness.watchTaskGraph(context) delivers the same value as a watch, like a conversation's watch().

typescript
const graph = await harness.taskGraph(context);
graph.subscribe((value) => {
	for (const node of Object.values(value.tasks)) {
		const status = node.state.status === "waiting" ? `waiting on ${node.state.on.join(", ")}` : node.state.status;
		console.log(`${node.id} ${node.kind} ${status}`, node.owner ?? `conversation ${node.conversationId}`);
	}
});

A task appears with the commit that creates it and leaves with the commit that makes it terminal. Statuses are the committed ones: pending, running, waiting (with on and policy), and completing (with the held outcome's status). After a restart, tasks that were running show as pending until they run again. Whether a pending task is blocked by a missing definition is not part of the graph; harness.inspect() reports that. The graph lists live tasks only: once a subagent's owner task is terminal, a later task in its conversation is a top-level node, and the conversation's ConversationRecord.owner (also in its view's conversation) links it to its parent. 24-child-tasks.ts prints the checkout's tree while its payments run.

Your Own State

Documents are typed JSON objects committed together with entries. Define one, and edit it in a commit:

typescript
import { defineDoc } from "@earendil-works/pi-durable";

const Todos = defineDoc<{ items: string[] }>({
	kind: "app.todos",
	version: 1,
	scope: "conversation",
	history: "latest", // or "rewindable" to read old values with snapshotAsOf()
	fork: "initial", // what a fork starts with: "initial", "current", or "asOf"
	initial: () => ({ items: [] }),
	checkpointWhen: () => false, // required: true stores this change as a complete base, bounding replay
});

await root.commit(async (tx) => {
	(await tx.doc(Todos, root.id)).items.push("write docs");
}, context);
console.log(await harness.snapshot(Todos, root.id, context));

harness.watchDoc() and harness.documentState() observe one document like the view above. HarnessOptions.conversationCreated(tx, conversation) runs in every commit that creates or forks a conversation, including a tool's raw tx.createConversation(), so every conversation gets your documents; init in createConversation(), fork(), and root() writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through api or input.read, and treat an absent one as its default (11-extension-state.ts).

Usage and Cost

Each conversation keeps token and cost totals in docs["pi.usage"]: per provider/model for model responses, and per tool name for tool results that report usage. Failed and aborted attempts count too. For the whole Session:

typescript
const usage = await harness.usage(context); // { models: { "openai/gpt-6-sol": Usage }, tools: {...} }

Storage

Backend Import Notes
Memory MemoryStorage from the package root Nothing is persisted.
IndexedDB IndexedDbStorage.open(name) from @earendil-works/pi-durable/storage/indexeddb Browser-only storage in one IndexedDB database.
SQLite openNodeSqliteStorage(file) from @earendil-works/pi-durable/storage/sqlite/node One database file. WAL mode with synchronous = NORMAL: commits survive process crashes; the newest may be lost on power or host failure.
JSONL openNodeJsonlStorage(directory, context) from @earendil-works/pi-durable/storage/jsonl/node Append-only files in one directory. Pass { fsync: true } to flush before each commit marker. Keeps the whole history in memory too, so memory grows with it; use SQLite for long-running agents.
Cloudflare Durable Object openDurableObjectSqliteStorage(ctx.storage) from @earendil-works/pi-durable/storage/sqlite/cloudflare The SQLite storage of one SQLite-backed Durable Object; commits are its storage transactions.

In a browser, open IndexedDB storage with await IndexedDbStorage.open(name). One owner must hold the database at a
time; use a Web Lock around its lifetime rather than opening
it in competing tabs or workers. Call await navigator.storage.persist() when durable retention matters.

SQLite storage can namespace its tables and indexes when sharing a database: pass { prefix: "pi_" } to
SqliteStorage.open(database, options), openNodeSqliteStorage(path, options), or
openDurableObjectSqliteStorage(ctx.storage, options). Storage renders the prefixed names into its SQL, so adapters
need no support. A prefix must be a SQLite identifier and cannot begin with sqlite_; a host may reserve more, such
as _cf_ in Durable Objects. Changing the prefix does not migrate existing data.

Multiple Pi storages opened through the Cloudflare adapter with the same storage object automatically share an
operation queue. To share the database with host tables, create an adapter and use it for both Pi and all host queries;
direct storage calls bypass the queue and may be rolled back with a Pi transaction:

typescript
const database = new DurableObjectSqliteDatabase(ctx.storage);
const piStorage = await SqliteStorage.open(database, { prefix: "pi_" });
await database.exec("CREATE TABLE IF NOT EXISTS host_notes (text TEXT)");
await database.run("INSERT INTO host_notes (text) VALUES (?)", text);

One process owns a storage at a time; there is no cross-process locking. The portable SQLite and JSONL cores (/storage/sqlite, /storage/jsonl) run without Node APIs, for example on Bun or in Cloudflare Durable Objects, given an asynchronous SqliteDatabase facade or a FileSystem from @earendil-works/pi-durable/env.

SQLite adapters implement promise-based exec, run, get, all, transaction, and close. run, get, and all take SQL text plus positional bindings; adapters may cache prepared statements by SQL text. A transaction callback receives a transaction handle; all work in the transaction must use it, and the handle expires when the callback settles. Adapters must queue unrelated operations and other transactions until the transaction finishes, so calling database itself inside the callback never settles:

typescript
await database.transaction(async (transaction) => {
	await transaction.exec("CREATE TABLE example (value TEXT)");
	await transaction.run("INSERT INTO example (value) VALUES (?)", "stored atomically");
});

Custom backends can run the shared conformance suite with any Vitest- or Jest-compatible runner:

typescript
import { registerStorageConformance } from "@earendil-works/pi-durable/testing";
import { describe, expect, it } from "vitest";

registerStorageConformance({ describe, expect, it }, "My Storage", async (use) => {
	const storage = await openMyStorage();
	try {
		await use(storage);
	} finally {
		await closeMyStorage(storage);
	}
});

The package root loads TypeBox, because the tool task validates arguments with pi-ai's validateToolArguments(). That costs about 23 MB of peak RSS unbundled, about 4 MB in a tree-shaken bundle.

Errors

Three rules decide what an error does:

  1. A storage error ends the Harness. The call that hit it rejects with the error. Everything after it, and every pending wait, rejects with SessionFailed (its cause is that error); watches and event streams end with session_failed; running tasks get their abort signal; onReport gets the error once. The Harness then closes itself. Nothing is retried; a custom storage retries its own transient failures. Reopen to recover: commits are atomic, so the store is consistent, and resubmitting with the same requestId finds a submission that was already admitted.
  2. An invalid read fails only that call: a storage read throws StorageRequestError for a bad cursor, an unknown conversation, or history a document does not keep. A read you cancelled fails only itself too. The exemption covers reads only: any error from commit() or mintId(), StorageRequestError included, ends the Harness as in rule 1.
  3. Your code fails only its own unit: a throwing task phase faults that task, a throwing tool gets an error result, a throwing hook is reported and skipped (beforeTool blocks the call), and a throwing commit callback rolls back that commit. Listeners that throw, or return a rejected promise, are reported and the others still run. The exception is a host RegistryReader whose snapshot() throws: the Harness cannot schedule without it, so it fails the Harness like a storage error.

harness.closed settles once the Harness has closed, by close() or after a storage error, so a host restarts on failure like this:

typescript
harness.closed.then((end) => {
	if (end.reason === "failed") reopen(end.error);
});

A failure closes the Harness by itself; closed settles once that close is done, even if the storage could not close cleanly. Closing waits for running task code to return, so code that ignores its abort signal delays closed.

Examples

Runnable examples live in test/examples. Run one from this package directory with:

bash
node --conditions=source --experimental-strip-types test/examples/14-chat.ts
Example Shows
14-chat One question and answer
16-real-model Streaming an answer from OpenAI
17-coding-tools A tool-using turn on JSONL storage
18-print Print mode: submit a prompt, print the answer
19-json JSON mode: agent events or raw view operations, on SQLite, JSONL, or memory
20-inbox Steers, follow-ups, writes, and withdrawal while busy
21-late-join Attaching a view and an event stream mid-run
22-subagent-foreground A replay-safe subagent tool whose child the call owns, with the child's events under the call
23-subagent-background Persistent subagents: spawn, steer, stop, list, answers reported back, restart-safe
24-child-tasks A checkout that owns and waits for four payments: failFast, abort, restart
25-compaction A long chat compacted in the background, manually, and after a context overflow
26-coding-agent CodingTools, live settings from a settings object, an environment that follows the conversation's directory
27-plan-mode A read-only plan mode as an extension with its own document, switched with configure()
28-reviewer A reviewer conversation with its own model, extensions, tools, directory, and review loop
29-sandbox-per-conversation An environment per conversation, looked up from an app document
30-tool-override A same-name bash for some conversations, and a wrapper that times whichever bash won
31-reload-and-restart Reloading an extension mid-call, and stored choices surviving a restart
00–13 The layers underneath: sessions, documents, forks, watches, the Harness, agent configuration, reload, extension state, tasks, recovery

Examples that call OpenAI need OPENAI_API_KEY; most use the faux provider otherwise.

Design Documents

Benchmarks: npm run bench:storage, npm run bench:storage:memory, npm run bench:tool-output, and npm run bench:nested-tools.

License

MIT


Overview (`packages/env/README.md`)

@earendil-works/pi-env

Remote execution environments for Pi Durable: an agent's tools run on another machine, usually over SSH,
while the Durable worker, its storage and credentials stay local.

  • pi-env (daemon/): a small Rust program that runs on the remote machine. It speaks a framed protocol on stdin and
    stdout (docs/protocol.md) and performs file operations and commands there.
  • RemoteExecutionEnv: a Durable ExecutionEnv that talks to the daemon through a Connection. Its results match
    NodeExecutionEnv running on the remote machine; only error messages may differ
    (docs/semantics.md).
ts
import { acceptHostKey, connectSsh, RemoteExecutionEnv, scanHostKey, sshConnection } from "@earendil-works/pi-env";

const target = { host: "gpu-box", knownHostsFile: "/data/ssh/known_hosts", hostKeyAlias: "pi-env-gpu" };
// Once: show the host's key fingerprint to the owner, who compares it out of band and accepts it.
const { lines, fingerprints } = await scanHostKey(target);
await acceptHostKey(target, lines);

// Detects the remote system, deploys the daemon if missing (named by its SHA-256 and verified before every start),
// and returns a connection that starts it over ssh.
const { connection } = await connectSsh(target);
const env = new RemoteExecutionEnv({ connection, id: "pi-env:gpu", cwd: "/home/me/project" });

// Or lazily: nothing happens until the first operation, which detects, deploys and connects. A failure (no network,
// untrusted host key) is that operation's error, and the next operation tries again.
const lazy = sshConnection(target);
const lazyEnv = new RemoteExecutionEnv({ connection: lazy.connection, id: "pi-env:gpu", cwd: "/home/me/project" });

The package ships the daemon for every supported remote system in bin/. ssh runs with BatchMode, strict host-key
checking against the application's own known-hosts file under a fixed alias, no forwarding of any kind, no shared
connections or configured commands, and without forwarding the local locale. On Windows, detection and deployment go
through PowerShell, and the daemon starts through the server's default shell (cmd.exe or PowerShell).

env.listFiles(path, options, context) lists the tree below a directory in the daemon, as fd --hidden --exclude .git
does without following symbolic links: files, directories and links as relative / paths, skipping what git ignores
inside work trees, bounded by maxEntries, maxBytes and timeoutMs with a truncated flag, and stopped by aborting
the context. It is Durable's optional ExecutionEnv.listFiles. A daemon released before it reports not_supported
(hello.features lacks listFiles), and callers fall back to their own walk. For local sessions,
LocalExecutionEnv is NodeExecutionEnv plus listFiles through a daemon on the same machine:

ts
const local = new Connection({ command: [localDaemon()] }); // owned by the caller; close() it at shutdown
const env = new LocalExecutionEnv({ cwd: "/home/me/project", connection: local });
const listing = await env.listFiles(".", { exclude: ["node_modules"] }, context);
// { ok: true, value: { entries: [{ path: "src", kind: "directory" }, { path: "src/a.ts", kind: "file" }], truncated } }

nodePlatform(await connection.info()) gives the remote system in Node's names (win32, darwin, x64, arm64),
as process.platform and process.arch spell them.

openTerminal(connection, options) runs a program, by default the user's login shell, in a pseudo-terminal of the
daemon: raw output, input, resizing and its exit code, for interactive terminals. localDaemon() is the daemon for this
machine, for terminals without SSH (new Connection({ command: [localDaemon()] })). Terminals are not supported on
Windows yet.

Supported remote systems: Linux, macOS, Android (Termux) and Windows, on x86-64 and arm64. On Windows, string commands
run through Git Bash as NodeExecutionEnv runs them there; argv commands run directly.

Development

npm run build:daemon builds the daemon with Cargo; the tests talk to daemon/target/debug/pi-env over a pipe, or to
the binary named by PI_ENV_DAEMON (CI tests the release builds this way). They run Durable's env conformance suite
against RemoteExecutionEnv and compare random operation sequences and Durable's tools against NodeExecutionEnv on
the same machine. test/ssh-external.test.ts runs the suite over a real SSH server when PI_ENV_SSH_HOST is set.


Overview (`packages/evals/README.md`)

Pi evals

Behavioral evals for Pi's coding agent, built with vitest-evals.

File conventions

Eval definitions are flat under evals/:

  • *.docs.eval.ts is a documentation-lift eval. eval:docs runs each case in isolated without_docs and with_docs containers and reports lift.
  • Other *.eval.ts files are host evals. eval:host runs them with Vitest on this machine. They are ordinary vitest-evals suites, not paired comparisons.

Runner code lives in src/:

  • cli.ts orchestrates a comparison
  • docker.ts builds the two images, discovers cases, and runs one isolated arm
  • plan.ts expands cases into (case, variant, repetition) tasks
  • report.ts reads Vitest JSON, pairs arms, and computes lift
  • harness.ts is the vitest-evals adapter

Eval suites and their fixtures live under evals/. Image build files live in docker/.

Run evals

Host evals (smoke, documentation audit) and documentation-lift evals need PI_PROVIDER and PI_MODEL.

bash
PI_PROVIDER=openai-codex PI_MODEL=gpt-5.6-sol npm run eval -w packages/evals

That runs host evals, then the documentation comparison. Extra CLI flags after -- go to eval:docs only.

Host only:

bash
PI_PROVIDER=openai-codex PI_MODEL=gpt-5.6-sol npm run eval:host -w packages/evals

One host suite:

bash
PI_PROVIDER=openai-codex PI_MODEL=gpt-5.6-sol \
  npm run eval:host -w packages/evals -- evals/documentation-audit.eval.ts

Run documentation comparisons

From the repository root:

bash
npm run eval:docs -w packages/evals -- \
  --provider openai-codex \
  --model gpt-5.6-sol

PI_PROVIDER and PI_MODEL provide the same defaults. Both values are required.

The default is one run per variant. Increase repetitions explicitly when measuring stability:

bash
npm run eval:docs -w packages/evals -- \
  evals/extensions.docs.eval.ts \
  --runs-per-variant 5

PI_EVAL_RUNS_PER_VARIANT=5 is equivalent. Vitest filters are applied during discovery:

bash
npm run eval:docs -w packages/evals -- -t "adds the model"

The runner:

  1. Mounts the repository ephemerally for a Docker build, packs the current workspace packages using the repository's consumer-install machinery, then creates separate without_docs and with_docs images from the staged runtime.
  2. Discovers the selected cases in both images and requires identical cohorts.
  3. Plans every (case, variant, model, runNumber) arm before execution.
  4. Runs each arm in a fresh container. A failed or missing arm is recorded and the planned cohort continues.
  5. Reads native Vitest JSON through @vitest-evals/core/node when a report exists.
  6. Pairs exact arms and writes the comparison report. Blocked pairs withhold headline lift; the process exits nonzero.

Repetition order alternates by run number to reduce order bias.

Documentation variants

without_docs omits the coding-agent README.md, CHANGELOG.md, docs/, and examples/, then removes the Pi documentation-routing section from the default system prompt.

with_docs includes those files and uses the unchanged default prompt.

Both variants install the same local workspace tarballs. Existing npm overrides ensure coding-agent's internal Pi dependencies also come from the current repository rather than the registry. Documentation and source files from internal dependency packages are removed symmetrically so they cannot act as alternate instructions. Startup validates the image allowlist and verifies that the installed coding-agent package resolves from dist/. Eval definitions, evaluator helpers, fixtures, and Vitest configuration are root-owned and unreadable after the harness permanently drops to an unprivileged UID. Each run receives a new home, agent directory, workspace, session directory, and container filesystem.

Documentation evals allow only read, write, edit, grep, find, and ls by default. They do not expose shell or web-search tools. Provider traffic still requires container network access, so Docker alone cannot prove that arbitrary code written by an agent never uses the network.

Results

Each invocation creates an ignored .eval/<timestamp>_/ directory containing:

  • protocol.json: model, image IDs, cases, tasks, and protocol digest.
  • expected-runs.json: the complete planned cohort.
  • observations.jsonl: normalized outcomes and telemetry.
  • tasks/*/vitest.json: native JSON for each isolated arm.
  • <variant>/sessions/*/session.jsonl: native Pi sessions.
  • report.json and report.txt: paired comparisons.

A pair contributes to pass-rate lift only when both arms produce exactly one score. Missing, duplicate, skipped, pending, unscored, or errored arms block the pair. If any pair in an eval set is blocked, headline pass rates are withheld. Missing telemetry remains unavailable rather than being treated as zero.

The report flags no lift, negative deltas, saturated controls or treatments, and observed flakiness. One repetition cannot establish stability.

Artifacts may contain prompts, responses, generated code, and tool output.

Write an eval

Use one ordinary describeEval(...) suite and one explicit run(...) call per case:

ts
import { describeEval, StructuredOutputJudge } from "vitest-evals";
import { createPiDocumentationEvalHarness } from "../src/harness.ts";

const harness = createPiDocumentationEvalHarness();
const judge = StructuredOutputJudge({ expected: { ok: true }, match: "strict", allowExtras: false });

describeEval("Target workflow", { harness, judges: [judge], judgeThreshold: null }, (it) => {
  it("completes the task", async ({ run }) => {
    await run("Complete the target task.");
  });
});

The outer runner owns variants, repetitions, isolation, identity, persistence, and reporting. Eval files should contain only scenario setup, the model task, and deterministic grading.

Use judgeThreshold: null for comparative scoring. A low score is data, not an infrastructure failure. Reserve Vitest assertions for broken suite invariants.


Overview (`packages/mcp/README.md`)

@earendil-works/pi-mcp

A small, standalone Model Context Protocol client. It does not depend on the official MCP SDK or other pi packages.

The package provides a transport-neutral client core, stdio and Streamable HTTP transports, and an in-memory testing transport.

Usage

typescript
import { McpClient, StdioTransport } from "@earendil-works/pi-mcp";

const transport = new StdioTransport({
	command: "npx",
	args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
});
const client = new McpClient({
	name: "my-client",
	version: "1.0.0",
	roots: [{ uri: "file:///workspace", name: "workspace" }],
});

await client.connect(transport);
const tools = await client.listTools();
const result = await client.callTool("search", { query: "MCP" });
await client.close();

For a remote server, use new StreamableHttpTransport({ url, headers }). Fetch can be injected for proxying or custom networking.

Tools for an LLM

toLlmContent(result) converts a CallToolResult to text and image content for a model, in the shape of @earendil-works/pi-ai's TextContent and ImageContent. Text and images pass through, embedded text and image resources are unwrapped, and audio, resource links, and binary resources become short text placeholders. A result without content blocks but with structuredContent becomes its JSON.

Wrapping an MCP tool as a pi-agent-core AgentTool:

typescript
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { toLlmContent } from "@earendil-works/pi-mcp";
import { Type } from "typebox";

const tools: AgentTool[] = (await client.listTools()).map((tool) => ({
	// Providers allow at most 64 characters of [A-Za-z0-9_-].
	name: `mcp_${tool.name}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64),
	label: tool.title ?? tool.name,
	description: tool.description ?? tool.name,
	// Providers require an object schema, and some reject one without `properties`.
	parameters: Type.Unsafe({ ...tool.inputSchema, type: "object", properties: tool.inputSchema.properties ?? {} }),
	execute: async (_toolCallId, params, signal) => {
		const result = await client.callTool(tool.name, params as Record<string, unknown>, { signal });
		// MCP reports tool failures in the result instead of as a protocol error.
		return { content: toLlmContent(result), details: undefined, isError: result.isError === true };
	},
}));

The mcp-codemode example also forwards progress, passes structuredContent through, and lets @earendil-works/pi-codemode scripts call the tools.

OAuth

@earendil-works/pi-mcp/oauth provides the MCP OAuth client subset without depending on the official SDK:

typescript
import { McpClient, StreamableHttpTransport } from "@earendil-works/pi-mcp";
import {
	adaptOAuthProvider,
	authorizeMcp,
	McpOAuthAuthorizationRequiredError,
	McpOAuthProvider,
	OAuthCallbackServer,
} from "@earendil-works/pi-mcp/oauth";

const serverUrl = "https://mcp.example.com/mcp";
const callback = await OAuthCallbackServer.listen();
let authorizationUrl: URL | undefined;
const oauth = new McpOAuthProvider({
	serverUrl,
	redirectUrl: callback.redirectUrl,
	clientMetadata: { client_name: "My MCP client" },
	onRedirect: (url) => {
		authorizationUrl = url;
	},
});

const connect = () => {
	const client = new McpClient({ name: "my-client", version: "1.0.0" });
	return {
		client,
		connected: client.connect(
			new StreamableHttpTransport({ url: serverUrl, authProvider: adaptOAuthProvider(oauth) }),
		),
	};
};

const first = connect();
try {
	await first.connected;
} catch (error) {
	if (!(error instanceof McpOAuthAuthorizationRequiredError)) throw error;
	const state = await oauth.state();
	const result = callback.waitForCallback(state);
	// Open authorizationUrl in the user's browser here.
	const { code } = await result;
	await authorizeMcp(oauth, { serverUrl, authorizationCode: code });
}

const { client, connected } = connect();
await connected;

Inject McpOAuthStateStore into McpOAuthProvider for durable credentials. The package does not open a browser or choose where credentials are stored.

The OAuth implementation is adapted from the MIT-licensed Model Context Protocol TypeScript SDK v1.29.0. Its license is included under LICENSES/.

An MCP transport owns framing and I/O. It delivers individual JSON-RPC messages to McpClient; the client owns request correlation, initialization, timeouts, cancellation, server requests, and protocol-level helpers.

Supported protocol surface

  • MCP protocol version 2025-11-25, accepting servers that negotiate 2025-06-18, 2025-03-26, or 2024-11-05
  • initialization and notifications/initialized
  • ping
  • paginated tools/list
  • tools/call, including structured content
  • progress notifications and timeout renewal
  • request cancellation
  • Streamable HTTP sessions, the server-to-client GET stream with reconnection, and resumption of dropped response streams with Last-Event-ID
  • stdio shutdown per the spec (close stdin, then SIGTERM, then SIGKILL), applied to the server's whole process group
  • server ping and roots/list requests
  • logging and tool-list-change notifications through the generic notification API
  • OAuth protected-resource and authorization-server discovery
  • PKCE authorization code flow, dynamic client registration, token refresh (one refresh shared by concurrent 401s), and step-up authorization for insufficient_scope

Batch JSON-RPC messages, legacy HTTP+SSE, servers, sampling, and tasks are outside the initial core.

Testing

@earendil-works/pi-mcp/testing exports createInMemoryTransportPair() for client and adapter tests.


Overview (`packages/telemetry/README.md`)

@earendil-works/pi-telemetry

Vendor-neutral telemetry contracts and typed schema utilities for pi packages.

This package provides:

  • an explicit, callback-based TelemetryContext / TelemetrySpan contract;
  • a shared NOOP_TELEMETRY_CONTEXT;
  • opaque cross-process propagation and span links;
  • counters, up-down counters, histograms, and typed metric schemas;
  • a reference InMemoryTelemetryContext implementation;
  • serializable schema definitions with inferred TypeScript types;
  • no exporter, global current-span state, or dependency on a telemetry backend.

Applications can use the in-memory reference or provide an adapter for OpenTelemetry, Sentry, logs, or another backend. Pi packages pass telemetry contexts explicitly and define their domain schemas separately.

Table of Contents

Installation

bash
npm install @earendil-works/pi-telemetry

Telemetry Concepts

Telemetry describes what a program did while it was running. This package models that work using spans, attributes, events, statuses, and explicit context:

Concept Plain-language meaning
Span A timed record of one operation, such as loading an account or making an AI request. It begins before the work and ends when the work finishes.
Parent and child spans Operations can contain smaller operations. A request span might contain a cache lookup and a database query. Together they form a tree showing where time was spent.
Attribute A named fact attached to a span, such as provider: "openai", cache.hit: true, or item_count: 12. Attributes describe the operation and its result.
Event A named occurrence at a point during a span, such as retry.scheduled or cache.lookup. Events have no duration and may carry their own attributes.
Status The operation's outcome: ok or error. An error status may include an error name and message.
Context A handle identifying where new work belongs in the span tree. Starting a span from a context makes it a child of that context.

For example, loading an account could produce this telemetry:

text
example.account.load                         span
├─ attributes: account.id=123, found=true   facts about the span
├─ event: example.cache.lookup              occurrence during the span
│  └─ attribute: cache.hit=false            fact about the event
└─ status: ok                               final outcome

A span is diagnostic data, not business state. An adapter translates these generic concepts into the corresponding concepts used by OpenTelemetry, Sentry, logs, or another backend.

Core Context API

A TelemetryContext starts a span around a callback. The callback receives a TelemetrySpan, which is also the explicit parent context for child spans. Contexts optionally expose their adapter's metrics?: TelemetryMetrics and propagation?: TelemetryPropagation capabilities; child spans and extracted parents carry the same capabilities. A TelemetryContext adapter starts root spans; hosts retain this adapter. Invocation contexts and pi-ai request options carry a TelemetrySpan, never the adapter. A tracing adapter that only implements startSpan() is still valid.

typescript
import {
  NOOP_TELEMETRY_CONTEXT,
  type TelemetryContext,
} from '@earendil-works/pi-telemetry';

async function loadAccount(
  accountId: string,
  telemetryContext: TelemetryContext = NOOP_TELEMETRY_CONTEXT,
) {
  return telemetryContext.startSpan(
    {
      name: 'example.account.load',
      attributes: { 'example.account.id': accountId },
    },
    async (span) => {
      const account = await readAccount(accountId);
      span.setAttributes({ 'example.account.found': account !== undefined });
      return account;
    },
  );
}

Pass the callback span to lower-level work to create explicit nesting:

typescript
return telemetryContext.startSpan({ name: 'example.parent' }, async (parentSpan) => {
  return parentSpan.startSpan({ name: 'example.child' }, async (childSpan) => {
    childSpan.addEvent('example.cache.lookup', { 'example.cache.hit': true });
    return performWork();
  });
});

There is no public end() method. startSpan() owns settlement and keeps the span open until the callback's value or promise settles. For an expected failure represented by a normal return value, set the status explicitly:

typescript
return telemetryContext.startSpan({ name: 'example.save' }, async (span) => {
  const result = await save();
  if (!result.ok) {
    span.setStatus({
      status: 'error',
      error: { name: 'SaveError', message: result.reason },
    });
  }
  return result;
});

Adapter Contract

An adapter implements TelemetryContext and bridges the generic API to its backend. Typed inputs are trusted; malformed inputs have unspecified behavior. Adapters are not required to validate inputs or suppress adapter/backend failures.

The callback and recording contract is:

  • create a child span and invoke the callback synchronously, exactly once;
  • expose optional metric and propagation capabilities on its contexts, child spans, and extracted parents, including inert fallback spans;
  • preserve the callback's returned value and rejection value, returning a promise rejected with the same value after a synchronous throw;
  • keep the native span open until a returned promise settles;
  • treat normal completion as ok and throws/rejections as errors unless an explicit status was set;
  • make repeated setStatus() calls last-write-wins;
  • merge setAttributes() calls, with later defined values replacing earlier values and undefined ignored;
  • make recording methods synchronous;
  • ignore calls made after settlement.

Adapters may activate backend-native ambient context internally for automatic instrumentation, but pi code always propagates the parent through TelemetryContext arguments. Exporter buffering, flushing, sampling, backend IDs, and backend-specific context objects belong to the adapter. Use the adapter conformance suite to check these observable semantics.

An adapter may also implement TelemetryPropagation. Expose it through context.propagation so code can inject or extract using the context it already has. The root, spans, and extracted parents share the adapter's capability object:

typescript
interface TelemetryPropagation {
  inject(context: TelemetryContext): TelemetryCarrier | undefined;
  extract(carrier: TelemetryCarrier | undefined): TelemetryContext;
}
type TelemetryCarrier = Record<string, string>;

A carrier is an opaque JSON-safe string map. Adapters own its format; W3C traceparent and tracestate fit without requiring them. Transport code sends only the carrier, never a context or span:

typescript
const caller = new InMemoryTelemetryContext();
const provider = new InMemoryTelemetryContext();
await caller.startSpan({ name: 'rpc.client' }, async (span) => {
  const wire = JSON.stringify(caller.inject(span));
  await provider.extract(JSON.parse(wire)).startSpan(
    { name: 'rpc.server' },
    (serverSpan) => handleRequest(serverSpan),
  );
});

Injection returns a detached carrier or undefined for roots, no-op contexts, and unsupported contexts. Extraction returns a receiver-owned parent. Absent or unknown carriers fall back to the adapter's root. Extraction checks only that required carrier keys contain strings; identifiers are opaque and need no format validation. Both methods are synchronous. Trace metadata is diagnostics, never authorization. Transport boundaries own carrier size limits.

A link relates work without changing its parent. Use SpanOptions.links when the target is known at creation, or span.addLink() after the span has started:

typescript
await telemetry.startSpan(
  { name: 'activation', links: [{ context: firstGetter, attributes: { reason: 'trigger' } }] },
  async (activation) => {
    laterGetter.addLink({ context: activation });
    await activate();
  },
);

Link targets are TelemetryContext handles, including spans and extracted remote parents. For a remote link, first call adapter.extract(carrier). A settled target remains linkable and injectable; a settled source ignores addLink(). Links append in order, copy attributes, and ignore root, no-op, or unsupported contexts. The typed span starter's callback also supports addLink(); initial links use the low-level startSpan() API.

NOOP_TELEMETRY_PROPAGATION ignores its arguments: injection returns undefined, extraction returns NOOP_TELEMETRY_CONTEXT.

Metrics

An adapter may implement TelemetryMetrics alongside TelemetryContext and TelemetryPropagation. Applications configure one adapter, not separate backends for each capability. Expose the shared capability through context.metrics; child spans retain it, so nested model calls can record metrics without a separate configuration option. The in-memory adapter also implements the factory methods directly:

typescript
const bytes = telemetry.counter('network.bytes', {
  description: 'Encoded WebSocket bytes', unit: 'By',
});
bytes.add(encodedSize, { direction: 'out' });

const active = telemetry.upDownCounter('calls.active');
active.add(1);
active.add(-1);

const duration = telemetry.histogram('call.duration', {
  unit: 'ms', boundaries: [1, 10, 100, 1000],
});
duration.record(elapsedMs, { method: 'read' }, currentSpan);

Factories take an instrument name and optional InstrumentOptions (description, unit). Histograms additionally accept HistogramOptions.boundaries: callers supply finite, strictly increasing bucket hints. Backends own aggregation and may translate these hints. Callers are responsible for finite measurements and non-negative counter increments; up-down counters and histograms may use signed values. Histogram record(value, attributes?, context?) optionally correlates a measurement with a span, called an exemplar. Counter add(value, attributes?) has no context argument.

Factories and recording methods are synchronous and trust typed inputs. Adapters need not validate measurement values, metadata, attributes, or boundaries; malformed inputs have unspecified behavior. NOOP_TELEMETRY_METRICS returns shared frozen instruments that discard measurements. Record only values already available to business code; do not serialize or measure extra data for telemetry.

Use only low-cardinality attributes: bounded values such as direction or method, not request IDs, user IDs, prompts, or arbitrary paths. The low-level attribute bag remains open; instrumentation owns this policy, not backend-specific heuristics.

Typed metric schemas

TelemetrySchemaDefinition.metrics optionally declares instrument names, kinds, metadata, and required/optional attributes. createTypedMetrics() combines one or more schemas and rejects duplicate metric names, incorrect instrument kinds, unknown attributes, and missing required attributes at compile time:

typescript
const schema = defineTelemetrySchema({
  version: 1,
  spans: {},
  metrics: {
    'network.bytes': {
      kind: 'counter', description: 'Encoded bytes', unit: 'By',
      attributes: {
        direction: {
          type: 'string', required: true, values: ['in', 'out'],
          description: 'Direction', cardinality: 'low',
        },
      },
    },
  },
} as const);

const metrics = createTypedMetrics(telemetry, [schema]);
metrics.counter('network.bytes').add(encodedSize, { direction: 'out' });

Metric definitions use kind: 'counter' | 'upDownCounter' | 'histogram'; only histograms may declare boundaries. Metric attributes use the same scalar/array definitions as spans, but reject cardinality: 'high'. Omitting the cardinality annotation does not waive the low-cardinality policy. Unlike the typed span starter, the metric helper reads and retains schema instrument metadata at runtime. It performs no runtime attribute validation.

InMemoryTelemetryContext.getMetrics() returns ordered, detached RecordedTelemetryMeasurement snapshots: kind, name, optional description/unit/boundaries, value, copied attributes, and a carrier-shaped exemplar context or null. This is a measurement log, not aggregated counters or buckets; storage is unbounded. Measurement times are retained privately for store queries, not exposed in these snapshots.

No-op Context

Use NOOP_TELEMETRY_CONTEXT when telemetry is optional:

typescript
import { NOOP_TELEMETRY_CONTEXT } from '@earendil-works/pi-telemetry';

const result = await NOOP_TELEMETRY_CONTEXT.startSpan(
  { name: 'example.operation' },
  () => runOperation(),
);

The no-op context:

  • invokes callbacks synchronously;
  • preserves returned values and asynchronous rejections, and converts a synchronous throw to a promise rejected with the same value;
  • uses one shared frozen inert span, including for nested spans;
  • exposes NOOP_TELEMETRY_METRICS and NOOP_TELEMETRY_PROPAGATION on its root and spans;
  • does not inspect or retain names, attributes, events, or statuses.

In-Memory Reference Adapter

InMemoryTelemetryContext is the backend-neutral reference implementation. It is useful for tests, local diagnostics, and applications that intentionally want process-local capture without an exporter:

typescript
import { InMemoryTelemetryContext } from '@earendil-works/pi-telemetry';

const telemetry = new InMemoryTelemetryContext();

await telemetry.startSpan(
  { name: 'example.operation', attributes: { input: 'demo' } },
  async (span) => {
    span.addEvent('example.started');
    span.setAttributes({ output_count: 3 });
  },
);

console.log(telemetry.getSpans());

getSpans() returns detached snapshots in span-start order. Each RecordedTelemetrySpan contains a deterministic numeric ID, local parent ID, carrier-shaped context and parentContext snapshots, ordered links, merged attributes, ordered events, final status, settlement state, and deterministic end sequence. These normalized snapshots expose no timestamps. A remote parent has parentId: null but a non-null parentContext; a root has both null. A RecordedTelemetryLink contains a target carrier and copied attributes.

The adapter implements TelemetryPropagation with its own small carrier codec: a unique recorder scope, a deterministic numeric span ID, and an inherited trace ID. The carrier survives JSON round trips and works across independent processes without a shared registry. Its keys are an implementation detail, not a public wire format for other adapters.

The adapter is safe to use as an ordinary TelemetryContext, but storage is unbounded and process-local. Create a fresh instance to isolate tests or recording scopes, and do not capture sensitive attributes unless the caller's data policy allows them.

Stored Telemetry

TelemetryStore separates querying from recording. Both InMemoryTelemetryContext and @earendil-works/chord/telemetry-sqlite implement it:

  • findTraces(query?) returns complete locally stored traces, newest trace start first. Filters include the earliest stored span's name, inclusive startMin/startMax, errors: true, and scalar attributes. All requested attributes must match one span, anywhere in the trace. Pages default to 20 traces; pass the returned opaque cursor with the same filters to continue. Equal start times are ordered by trace ID.
  • getTrace(traceId) returns a trace or undefined.
  • findMetrics({ name, startMin?, startMax?, attributes? }) returns metric rows with count, sum, minimum, and maximum. Time bounds are inclusive interval-start times. The in-memory adapter maps each raw measurement to a count-one row in its ten-second interval; SQLite aggregates measurements per series and interval.

Only settled spans are returned. A trace ID is the root's globally unique span ID (<recorder UUID>:<counter>) and propagates with the parent identity. Stores return the spans available locally: remote or expired parents can be absent, and a child can appear before its parent settles. Pagination is not a snapshot across requests; late settlements and retention can change a trace's earliest stored span. SQLite reads committed rows without implicitly flushing.

StoredSpan includes epoch-millisecond startedMs/endedMs, event offsets in timeMs, and links carrying both target traceId and spanId. Duration uses a monotonic clock; wall clocks across processes may disagree. Merge spans from several stores by trace ID; parent relationships, not timestamp containment, define the tree.

Rendering is a separate subpath, kept out of the root entry graph:

typescript
import { renderTrace } from '@earendil-works/pi-telemetry/render';

const page = await telemetry.findTraces({ errors: true, limit: 10 });
for (const trace of page.traces) console.log(renderTrace(trace.spans));

renderTrace() accepts stored spans, deduplicates IDs, treats absent parents as roots, and prints attributes, durations, statuses, event offsets, and cross-trace links. It never follows links or queries a store.

Adapter Conformance

@earendil-works/pi-telemetry/testing exports a runner-independent conformance suite modeled as grouped cases. A fixture supplies a fresh context and converts its backend's finished spans into normalized RecordedTelemetrySpan snapshots:

typescript
import {
  createTelemetryAdapterConformance,
  type TelemetryAdapterFixture,
} from '@earendil-works/pi-telemetry/testing';
import { describe, it } from 'vitest';

const conformance = createTelemetryAdapterConformance(async () => {
  const adapter = createMyTelemetryAdapter();
  return {
    context: adapter.context,
    getSpans: async () => adapter.normalizedSpans(),
    async [Symbol.asyncDispose]() {
      await adapter.close();
    },
  } satisfies TelemetryAdapterFixture;
});

for (const group of new Set(conformance.map((testCase) => testCase.group))) {
  describe(group, () => {
    for (const testCase of conformance.filter((candidate) => candidate.group === group)) {
      it(testCase.name, () => testCase.run());
    }
  });
}

The suite checks capability identity on roots, child spans, extracted parents and inert spans, synchronous single admission, result and rejection identity, automatic and explicit status, attribute merging, event ordering, inert post-settlement calls, nested and concurrent parentage, and links. Fixtures may supply propagation to additionally check JSON round trips, multiple adapter hops, and root fallback for absent or unknown carriers. Fixtures may also supply metrics and getMetrics() to check ordered measurements, histogram exemplars, and input copying. It does not test malformed inputs or require suppression of adapter/backend failures. getSpans() may flush an asynchronous exporter before returning. The testing subpath uses Node's assertion APIs; the root telemetry package remains runtime-neutral.

Typed Schemas

The low-level span API intentionally accepts open names and attribute bags so adapters remain generic. Domain packages can define closed, serializable schemas and infer exact TypeScript types from them.

typescript
import {
  createTypedSpanStarter,
  defineTelemetrySchema,
} from '@earendil-works/pi-telemetry';

export const EXAMPLE_TELEMETRY_SCHEMA = defineTelemetrySchema({
  version: 1,
  spans: {
    'example.read': {
      description: 'Read one resource',
      parents: { kind: 'any' },
      startAttributes: {
        'example.resource': {
          type: 'string',
          required: true,
          values: ['account', 'project'],
          description: 'Resource kind',
        },
      },
      endAttributes: {
        'example.item_count': {
          type: 'number',
          description: 'Number of returned items',
        },
      },
      events: {
        'example.cache': {
          description: 'Cache lookup result',
          attributes: {
            'example.cache.hit': {
              type: 'boolean',
              required: true,
              description: 'Whether the cache contained the resource',
            },
          },
        },
      },
      status: {
        default: 'ok',
        errorWhen: 'The read throws or returns an error result',
      },
    },
  },
} as const);

const startSpan = createTypedSpanStarter(
  telemetryContext,
  [EXAMPLE_TELEMETRY_SCHEMA],
);

The starter exposes one overload per span and checks names and attributes at compile time. Union-valued names must be narrowed before a call, preserving the relationship between each runtime name and its attribute schema. Its callback receives a child starter over the same schemas, already bound to the callback span:

typescript
await startSpan(
  'example.read',
  { 'example.resource': 'account' },
  async (span, startChildSpan) => {
    span.addEvent('example.cache', { 'example.cache.hit': true });
    const accounts = await readAccounts();
    span.setAttributes({ 'example.item_count': accounts.length });

    await startChildSpan(
      'example.read',
      { 'example.resource': 'project' },
      async (childSpan) => {
        const projects = await readProjects();
        childSpan.setAttributes({ 'example.item_count': projects.length });
      },
    );

    return accounts;
  },
);

Start and Completion Attributes

startAttributes and endAttributes describe when an attribute is normally known, not separate runtime storage:

Schema field How values are recorded Requiredness
startAttributes Passed in the typed starter's attributes argument when the span is created Each definition explicitly sets required: true or false
endAttributes Added later through the schema-scoped span's setAttributes() method Always optional

Both sets become ordinary attributes on the same backend span. There is no separate end-attribute payload or end callback. In the preceding example, example.resource is known when example.read starts, while example.item_count is known only after readAccounts() returns:

typescript
await startSpan(
  'example.read',
  { 'example.resource': 'account' }, // required start attribute
  async (span) => {
    const accounts = await readAccounts();
    span.setAttributes({
      'example.item_count': accounts.length, // optional completion attribute
    });
    return accounts;
  },
); // resolving the callback settles the span

“End” means completion enrichment: an end attribute may be set at any point while the callback is active, and it may be omitted when unavailable. Calling setAttributes() zero times is valid. This matters for early failures, cancellation, and provider-specific data that may not exist on every path.

Repeated setAttributes() calls merge into the same attribute bag. A later defined value replaces an earlier value for the same key, while undefined is ignored. The schema-scoped method accepts only the current span's declared end attributes.

Attributes do not end the span. Returning, resolving, throwing, or rejecting from the callback controls settlement; startSpan() performs the actual end operation. Adapter calls made after settlement are inert.

A starter can compose multiple independently versioned schemas:

typescript
import { AGENT_TELEMETRY_SCHEMAS } from '@earendil-works/pi-agent-core';

const startAgentSpan = createTypedSpanStarter(
  telemetryContext,
  AGENT_TELEMETRY_SCHEMAS,
);

Inline schema arrays retain their tuple types automatically. Separately declared arrays should use as const. Literal duplicate span names across the array are rejected at compile time; schemas are not merged, inspected, or retained at runtime.

Schema-derived types reject missing required attributes, unknown keys, invalid closed-set values, undeclared events, and attributes on empty schemas. End attributes are always optional enrichment; the type system does not require setAttributes() to be called.

defineTelemetrySchema() is a typed identity function. It returns ordinary JSON-serializable data and performs no runtime validation or parent-rule enforcement.

Schema Metadata

Supported attribute types are:

  • string, number, and boolean;
  • string[], number[], and boolean[].

Attribute definitions support:

  • values: a closed set for scalar values;
  • elementValues: a closed set for array elements;
  • examples: documentation examples;
  • sensitive: marks data requiring special handling;
  • cardinality: records expected low or high cardinality.

Start and event attributes declare required. End attributes do not; see Start and Completion Attributes.

Parent metadata is descriptive schema data:

  • { kind: 'any' }: root or any caller span;
  • { kind: 'root_or_external' }: root or a caller-owned span outside the schema;
  • { kind: 'spans', spans: [...] }: only the listed schema spans.

Adapters do not need to understand schema objects. Instrumentation helpers and tests use them to keep emitted names and attributes consistent.

Pi Package Integration

Package ownership is intentionally split:

  • @earendil-works/pi-telemetry owns the vendor-neutral contract, no-op and in-memory reference contexts, schema utilities, and adapter conformance suite;
  • @earendil-works/pi-ai accepts a parent TelemetrySpan through telemetry in provider request options and exports AI_TELEMETRY_SCHEMA from its root and ./models entry points;
  • @earendil-works/pi-durable exports DURABLE_TELEMETRY_SCHEMA for short task-handler and submission spans, events, and metrics;
  • @earendil-works/pi-agent-core retains its existing agent-loop and session instrumentation and schemas.
typescript
import { AI_TELEMETRY_SCHEMA } from '@earendil-works/pi-ai/models';
import { DURABLE_TELEMETRY_SCHEMA } from '@earendil-works/pi-durable';

The AI and Durable schemas use pi-owned pi.ai.* and pi.durable.* names. Adapters may translate them to backend conventions without changing the emitted pi vocabulary.

Security and Portability

Telemetry is diagnostics, not durable application state. Do not persist a TelemetryContext, TelemetrySpan, or backend-native trace object in records, messages, snapshots, or deferred handles. An injected TelemetryCarrier may cross a transport or be stored as optional diagnostic correlation data. Changing adapters may lose correlation; carriers are not application state.

Attribute values are intentionally limited to primitive scalars and arrays. Domain instrumentation should avoid prompts, completions, tool arguments or output, file contents, provider payloads, headers, credentials, and free-form error details unless its schema and data policy explicitly allow them.

The package does not use AsyncLocalStorage or another runtime-specific ambient context API. It is suitable for Node.js, Bun, browsers, and workers; backend adapters remain responsible for their own runtime compatibility.

API Reference

Core types and values

Export Purpose
TelemetryContext Starts callback-managed child spans and optionally exposes adapter metrics/propagation
TelemetrySpan Records attributes, events, and status; also acts as a child context
SpanOptions Span name, optional start attributes and links
SpanLink Context target and optional link attributes
TelemetryCarrier Opaque JSON-safe string map
TelemetryPropagation Optional adapter injection and extraction capability
NOOP_TELEMETRY_PROPAGATION Shared passive propagation for disabled telemetry
TelemetryMetrics Optional adapter instrument factories
TelemetryCounter / TelemetryUpDownCounter Instruments with add()
TelemetryHistogram Instrument with record() and optional exemplar context
InstrumentOptions / HistogramOptions Description, unit, and histogram boundary hints
NOOP_TELEMETRY_METRICS Shared passive metric instruments
RecordedTelemetryMeasurement Captured measurement and optional exemplar
SpanAttributes / AttributeValue Open adapter-level attribute bag and supported values
SpanStatus Explicit ok or error status
NOOP_TELEMETRY_CONTEXT Shared passive context for disabled telemetry
InMemoryTelemetryContext Reference adapter with deterministic process-local recording
RecordedTelemetrySpan Normalized captured span snapshot
RecordedTelemetryEvent Normalized captured event snapshot
RecordedTelemetryLink Captured target carrier and link attributes
TelemetryStore Settled trace discovery, trace retrieval, and metric queries
StoredSpan / StoredTrace Backend-independent timed span and trace snapshots
TraceQuery / MetricQuery Trace filters, pagination, and metric filters
StoredMetric Metric interval row with count, sum, minimum, and maximum

Schema definitions and inference

Export Purpose
defineTelemetrySchema() Typed identity helper for serializable schema data
createTypedSpanStarter() Binds a parent context to one or more schema vocabularies
createTypedMetrics() / TypedTelemetryMetrics Binds metric names, kinds, metadata, and attributes to schemas
TelemetryMetricDefinition / TelemetryMetricAttributeDefinition Serializable instrument metadata and low-cardinality attribute definitions
SchemaTelemetryCounter / SchemaTelemetryHistogram Schema-constrained recording methods
TypedSpanStarter Exact starter type with recursively child-bound callbacks
TelemetrySchemaDefinition Top-level schema shape
TelemetrySpanDefinition Span metadata, parents, attributes, events, and status rule
TelemetryAttributeType Supported scalar and array type names
TelemetryAttributeMetadata Description, sensitivity, and cardinality metadata
TelemetryAttributeDefinition Attribute type, allowed values, examples, and metadata
TelemetryStartAttributeDefinition Start attribute definition with requiredness
TelemetryEventAttributeDefinition Event attribute definition with requiredness
TelemetryEventDefinition Event description and attribute definitions
TelemetryParentDefinition Open, external-root, or finite schema-parent rule
TelemetrySchemaSpanName Union of declared span names
TelemetrySchemaSpanStartAttributes Exact inferred start attributes for one span
TelemetrySchemaSpanEndAttributes Optional inferred end attributes for one span
TelemetrySchemaSpanEventName Union of events declared by one span
TelemetrySchemaSpanEventAttributes Exact inferred attributes for one event
SchemaTelemetrySpan Span view restricted to one schema span
TelemetrySchemaSpanUnion Discriminated union of all spans in a schema
InferStartAttributes Required and optional values inferred from start definitions
InferOptionalAttributes Optional values inferred from end definitions
InferEventAttributes Required and optional values inferred from event definitions
InferRequiredAndOptionalAttributes Shared inference utility for definitions with requiredness
ExactTelemetryAttributes Rejects keys outside an expected attribute set

Testing subpath

Export Purpose
createTelemetryAdapterConformance() Creates runner-independent adapter conformance cases
TelemetryAdapterFixture Fresh context and normalized snapshot reader for one case
TelemetryAdapterFixtureFactory Creates isolated fixtures
TelemetryAdapterConformanceCase Grouped case that test runners execute

Development

From this package directory:

bash
npm test
npm run build

Repository-wide type checking, formatting, linting, and smoke checks run with:

bash
npm run check

License

MIT


Overview (`packages/tui/README.md`)

@earendil-works/pi-tui

Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.

Features

  • Interchangeable Renderers: Shared TUI interface with main-screen and alternate-screen implementations
  • Differential Rendering: Updates only changed lines or viewport rows
  • Application-owned Scrolling: Alternate-screen viewport supports mouse, trackpad, and keyboard navigation
  • Synchronized Output: Uses CSI 2026 for atomic screen updates (no flicker)
  • Bracketed Paste Mode: Handles large pastes correctly with markers for >10 line pastes
  • Component-based: Simple Component interface with render() method
  • Theme Support: Components accept theme interfaces for customizable styling
  • Built-in Components: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, MouseRegion, Spacer, Image, Box, Container, VStack, HStack, ScrollView
  • Inline Images: Renders images in terminals that support Kitty or iTerm2 graphics protocols
  • Autocomplete Support: File paths and slash commands

Quick Start

typescript
import { type TUI, Text, Editor, ProcessTerminal, TuiMainScreen, matchesKey } from "@earendil-works/pi-tui";

// Create terminal
const terminal = new ProcessTerminal();

// Create the default main-screen renderer through the shared TUI interface
const tui: TUI = new TuiMainScreen(terminal);

// Add components
tui.addChild(new Text("Welcome to my app!"));

import { defaultEditorTheme as editorTheme } from './test/test-themes.ts';
const editor = new Editor(tui, editorTheme);
editor.onSubmit = (text) => {
  console.log("Submitted:", text);
  tui.addChild(new Text(`You said: ${text}`));
};
tui.addChild(editor);

// Focus the editor so it receives keyboard input
tui.setFocus(editor);

// In raw mode Ctrl+C doesn't send SIGINT — intercept it here to allow exit
tui.addInputListener((data) => {
  if (matchesKey(data, 'ctrl+c')) {
    tui.stop();
    process.exit(0);
  }
});

// Start
tui.start();

Core API

TUI interface and renderers

TUI is the shared interface for component management, focus, overlays, input, lifecycle, terminal queries, and rendering. Choose a concrete renderer only when constructing the application:

  • TuiMainScreen renders into the main terminal buffer and preserves terminal scrollback.
  • TuiAltScreen renders a fixed-height viewport in the alternate terminal buffer with application-owned scrolling. When stopped, it restores the main buffer and prints the complete final document.
typescript
import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";

const tui: TUI = new TuiMainScreen(terminal);
// To use an application-owned viewport in the alternate terminal buffer instead:
// const tui: TUI = new TuiAltScreen(terminal);

tui.addChild(component);
tui.removeChild(component);
tui.start();
tui.stop();
tui.requestRender(); // Request a re-render

// Global debug key handler (Shift+Ctrl+D)
tui.onDebug = () => console.log("Debug triggered");

Colors and terminal styles

Colors are values that can be converted or mixed before terminal rendering:

typescript
import {
  colorToRgb,
  foregroundAnsi,
  getTerminalColorMode,
  mixColors,
  parseColor,
  rgbColor,
  styleText,
} from "@earendil-works/pi-tui";

const accent = parseColor("oklch(70% 0.12 220)");
const background = parseColor("#20242a");
const foreground = mixColors(accent, background, 0.2);

const text = styleText(
  "Ready",
  { fg: foreground, bg: background, bold: true },
  getTerminalColorMode(),
);

Color is an indexed ANSI color, an sRGB color, or an OKLCH color. Every color converts to sRGB, so color math such as mixColors() always works. Indices 0-15 follow the user's terminal palette, so their sRGB values are approximations. styleText() converts colors to truecolor or 256-color output based on the requested terminal mode.

parseColor() also accepts OKHSL, as in okhsl(250 60% 55%); okhslColor() builds it in code and colorToOkhsl() reads any color's OKHSL channels. OKHSL saturation is relative to the most the sRGB gamut allows at the hue and lightness, so every value is in gamut and equal saturation looks equally colorful across hues. OKHSL colors are converted to sRGB when created.

Conversions are not cached. OKLCH colors, especially ones outside the sRGB gamut, are more expensive to convert than sRGB or indexed colors. For colors used on every render, convert once and reuse the result:

typescript
const { r, g, b } = colorToRgb(mixColors(accent, background, 0.2));
const foreground = rgbColor(r, g, b); // cheap to render repeatedly
const foregroundCode = foregroundAnsi(foreground, getTerminalColorMode());

Alternate-screen viewport layouts

TuiAltScreen can render an explicit terminal-height layout. VStack and HStack allocate constrained regions, while ScrollView owns scrolling for one region. These semantics are intentionally unavailable on TuiMainScreen, where the terminal owns scrollback.

typescript
import {
  Container,
  isViewportTUI,
  ScrollView,
  Text,
  VStack,
} from "@earendil-works/pi-tui";

const transcript = new Container();
transcript.addChild(new Text("History"));

const editorAndFooter = new VStack([
  editor,
  new Text("status"),
]);

if (isViewportTUI(tui)) {
  tui.setLayoutRoot(new VStack([
    {
      component: new ScrollView(transcript, {
        follow: "end",
        primary: true,
        overscroll: "chain",
      }),
      basis: 0,
      grow: 1,
      minSize: 1,
    },
    {
      component: editorAndFooter,
      basis: "auto",
      shrink: 1,
      minSize: 1,
    },
  ]));
}

Stack entries support basis, grow, shrink, minSize, maxSize, and responsive visible callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. Press Ctrl+Shift+F to open or close its bordered search panel. The panel shows the configured previous/next shortcuts and provides clickable arrow controls; by default, Enter/Ctrl+G and Shift+Enter/Ctrl+Shift+G move between matches, and Escape also closes search. TuiAltScreenOptions.searchMatchStyle and searchCurrentMatchStyle customize match highlighting, while searchNavigationButtonStyle styles each arrow button and receives its hover state. TuiAltScreenOptions.scrollToEndIndicator renders a clickable label centered on the last row of a follow: "end" primary scroll view while it is scrolled away from the end; clicking it resumes end-following.

Layout geometry is rebuilt for each requested frame. Stateful components are retained, and their existing rendered-line caches remain effective. Calling render(width) directly on these layout components produces an unbounded document, which is also used when alt mode restores the main screen.

Overlays

Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI.

text
/* Detailed source-code truncated for AI context efficiency. */

Anchor values: 'center', 'top-left', 'top-right', 'bottom-left', 'bottom-right', 'top-center', 'bottom-center', 'left-center', 'right-center'

Resolution order:

  1. minWidth is applied as a floor after width calculation
  2. For position: absolute row/col > percentage row/col > anchor
  3. margin clamps final position to stay within terminal bounds
  4. visible callback controls whether overlay renders (called each frame)

Component Interface

All components implement:

typescript
interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
  invalidate(): void;
}
Method Description
render(width) Returns an array of strings, one per line. Each line must not exceed width or the TUI will error. Use truncateToWidth() or manual wrapping to ensure this.
handleInput?(data) Called when the component has focus and receives keyboard input. The data string contains raw terminal input (may include ANSI escape sequences).
handleMouse?(event) Called by TuiAltScreen for normalized pointer input targeted at the component.
invalidate() Required. Clear any cached render state so the next render() starts from scratch. Components without cached render state can use an empty implementation.

The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use wrapTextWithAnsi() so styles are preserved for each wrapped line.

Mouse Input

TuiAltScreen normalizes SGR mouse input and hit-tests components and overlays. Events contain component-local x/y, absolute screenX/screenY, bounds, button, modifiers, click count, and wheel delta. TuiMainScreen does not capture mouse input because the terminal owns its scrollback.

typescript
import type { TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";

handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
  if (event.type === "click" && event.button === "left") {
    this.expanded = !this.expanded;
    return { handled: true };
  }
  if (event.type === "press" && event.button === "left") {
    return { handled: true, capture: true, focus: true };
  }
  if (event.type === "drag") {
    this.updateFromPointer(event.x, event.y);
    return { handled: true, render: true };
  }
  return undefined;
}

Returning handled suppresses renderer-level fallback behavior. capture keeps subsequent drag and release events routed to the same component. focus requests keyboard focus. The optional render flag controls repainting: press, click, drag, and wheel default to rendering; move and release do not. Set render: true for a hover state that visibly changed, or render: false for a handled no-op. Render requests are coalesced and terminal output remains differential.

Unhandled gestures retain alternate-screen defaults: wheel input scrolls the nearest ScrollView and chains unused delta, primary-button drags select text, OSC 8 links open before parent click handlers, and unhandled right-click preserves configured paste behavior. A click is emitted only when press/release completes without a drag.

Use MouseRegion to add mouse behavior without changing a component's rendering:

typescript
const collapsible = new MouseRegion(content, (event) => {
  if (event.type !== "click" || event.button !== "left") return undefined;
  expanded = !expanded;
  return { handled: true };
});

Container and Box route events to nested children using geometry recorded by the last rendered frame, so pointer motion does not rerender children merely to hit-test them. Explicit VStack, HStack, and ScrollView layouts use the alternate-screen layout frame directly.

Focusable Interface (IME Support)

Components that display a text cursor and need IME (Input Method Editor) support should implement the Focusable interface:

typescript
import { CURSOR_MARKER, type Component, type Focusable, renderFakeCursor } from "@earendil-works/pi-tui";

class MyInput implements Component, Focusable {
  focused: boolean = false;  // Set by TUI when focus changes
  
  render(width: number): string[] {
    const marker = this.focused ? CURSOR_MARKER : "";
    // Emit marker right before the fake cursor
    return [`> ${beforeCursor}${marker}${renderFakeCursor(atCursor)}${afterCursor}`];
  }

  invalidate(): void {}
}

When a Focusable component has focus, TUI:

  1. Sets focused = true on the component
  2. Scans rendered output for CURSOR_MARKER (a zero-width APC escape sequence)
  3. Positions the hardware terminal cursor at that location
  4. Shows the hardware cursor only when showHardwareCursor is enabled

renderFakeCursor() wraps the cursor cell in zero-width markers. TUI renders them as reverse video. When showHardwareCursor is enabled, TUI removes the fake cursor that directly follows CURSOR_MARKER, so the focused component shows only the terminal cursor. Fake cursors without a preceding CURSOR_MARKER stay visible.

The hardware cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's showHardwareCursor argument or setShowHardwareCursor(true). The Editor and Input built-in components already implement this interface.

Container components with embedded inputs: When a container component (dialog, selector, etc.) contains an Input or Editor child, the container must implement Focusable and propagate the focus state to the child:

typescript
import { Container, type Focusable, Input } from "@earendil-works/pi-tui";

class SearchDialog extends Container implements Focusable {
  private searchInput: Input;

  // Propagate focus to child input for IME cursor positioning
  private _focused = false;
  get focused(): boolean { return this._focused; }
  set focused(value: boolean) {
    this._focused = value;
    this.searchInput.focused = value;
  }

  constructor() {
    super();
    this.searchInput = new Input();
    this.addChild(this.searchInput);
  }
}

Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position.

Built-in Components

Container

Groups child components.

typescript
const container = new Container();
container.addChild(component);
container.removeChild(component);

Box

Container that applies padding and background color to all children.

typescript
const box = new Box(
  1,                              // paddingX (default: 1)
  1,                              // paddingY (default: 1)
  (text) => chalk.bgGray(text)   // optional background function
);
box.addChild(new Text("Content"));
box.setBgFn((text) => chalk.bgBlue(text));  // Change background dynamically

Text

Displays multi-line text with word wrapping and padding.

typescript
const text = new Text(
  "Hello World",                  // text content
  1,                              // paddingX (default: 1)
  1,                              // paddingY (default: 1)
  (text) => chalk.bgGray(text)   // optional background function
);
text.setText("Updated text");
text.setCustomBgFn((text) => chalk.bgBlue(text));

TruncatedText

Single-line text that truncates to fit viewport width. Useful for status lines and headers.

typescript
const truncated = new TruncatedText(
  "This is a very long line that will be truncated...",
  0,  // paddingX (default: 0)
  0   // paddingY (default: 0)
);

Input

Single-line text input with horizontal scrolling.

typescript
const input = new Input();
input.onSubmit = (value) => console.log(value);
input.setValue("initial");
input.getValue();

Clicking positions the cursor and gives the input keyboard focus in alternate-screen mode.

Key Bindings:

  • Enter - Submit
  • Ctrl+A / Ctrl+E - Line start/end
  • Ctrl+W or Alt+Backspace - Delete word backwards
  • Ctrl+U - Delete to start of line
  • Ctrl+K - Delete to end of line
  • Ctrl+Left / Ctrl+Right - Word navigation
  • Alt+Left / Alt+Right - Word navigation
  • Arrow keys, Backspace, Delete work as expected

Editor

Multi-line text editor with autocomplete, file completion, paste handling, and vertical scrolling when content exceeds terminal height.

typescript
interface EditorTheme {
  borderColor: (str: string) => string;
  selectList: SelectListTheme;
}

interface EditorOptions {
  paddingX?: number;  // Horizontal padding (default: 0)
}

const editor = new Editor(tui, theme, options?);  // tui is required for height-aware scrolling
editor.onSubmit = (text) => console.log(text);
editor.onChange = (text) => console.log("Changed:", text);
editor.disableSubmit = true; // Disable submit temporarily
editor.setAutocompleteProvider(provider);
editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
editor.setPaddingX(1); // Update horizontal padding dynamically
editor.getPaddingX();  // Get current padding

Features:

  • Click-to-position cursor and clickable autocomplete rows in alternate-screen mode
  • Multi-line editing with word wrap
  • Slash command autocomplete (type /)
  • File path autocomplete (press Tab)
  • Large paste handling (>10 lines creates [paste #1 +50 lines] marker)
  • Horizontal lines above/below editor
  • Fake cursor rendering (hidden real cursor)

Key Bindings:

  • Enter - Submit
  • Shift+Enter, Ctrl+Enter, or Alt+Enter - New line (terminal-dependent, Alt+Enter most reliable)
  • Tab - Autocomplete
  • Ctrl+K - Delete to end of line
  • Ctrl+U - Delete to start of line
  • Ctrl+W or Alt+Backspace - Delete word backwards
  • Alt+D or Alt+Delete - Delete word forwards
  • Ctrl+A / Ctrl+E - Line start/end
  • Ctrl+] - Jump forward to character (awaits next keypress, then moves cursor to first occurrence)
  • Ctrl+Alt+] - Jump backward to character
  • Arrow keys, Backspace, Delete work as expected

Markdown

Renders markdown with syntax highlighting and theming support.

typescript
interface MarkdownTheme {
  heading: (text: string) => string;
  link: (text: string) => string;
  linkUrl: (text: string) => string;
  code: (text: string) => string;
  codeBlock: (text: string) => string;
  codeBlockBorder: (text: string) => string;
  quote: (text: string) => string;
  quoteBorder: (text: string) => string;
  hr: (text: string) => string;
  listBullet: (text: string) => string;
  bold: (text: string) => string;
  italic: (text: string) => string;
  strikethrough: (text: string) => string;
  underline: (text: string) => string;
  highlightCode?: (code: string, lang?: string) => string[];
}

interface DefaultTextStyle {
  color?: (text: string) => string;
  bgColor?: (text: string) => string;
  bold?: boolean;
  italic?: boolean;
  strikethrough?: boolean;
  underline?: boolean;
}

const md = new Markdown(
  "# Hello\n\nSome **bold** text",
  1,              // paddingX
  1,              // paddingY
  theme,          // MarkdownTheme
  defaultStyle    // optional DefaultTextStyle
);
md.setText("Updated markdown");

Features:

  • Headings, bold, italic, code blocks, lists, links, blockquotes
  • HTML tags rendered as plain text
  • Optional syntax highlighting via highlightCode
  • Padding support
  • Render caching for performance

Loader

Animated loading spinner.

typescript
const loader = new Loader(
  tui,                              // TUI instance for render updates
  (s) => chalk.cyan(s),            // spinner color function
  (s) => chalk.gray(s),            // message color function
  "Loading..."                      // message (default: "Loading...")
);
loader.start();
loader.setMessage("Still loading...");
loader.stop();

CancellableLoader

Extends Loader with Escape key handling and an AbortSignal for cancelling async operations.

typescript
const loader = new CancellableLoader(
  tui,                              // TUI instance for render updates
  (s) => chalk.cyan(s),            // spinner color function
  (s) => chalk.gray(s),            // message color function
  "Working..."                      // message
);
loader.onAbort = () => done(null); // Called when user presses Escape
doAsyncWork(loader.signal).then(done);

Properties:

  • signal: AbortSignal - Aborted when user presses Escape
  • aborted: boolean - Whether the loader was aborted
  • onAbort?: () => void - Callback when user presses Escape

SelectList

Interactive selection list with keyboard navigation.

typescript
interface SelectItem {
  value: string;
  label: string;
  description?: string;
}

interface SelectListTheme {
  selectedPrefix: (text: string) => string;
  selectedText: (text: string) => string;
  description: (text: string) => string;
  scrollInfo: (text: string) => string;
  noMatch: (text: string) => string;
}

const list = new SelectList(
  [
    { value: "opt1", label: "Option 1", description: "First option" },
    { value: "opt2", label: "Option 2", description: "Second option" },
  ],
  5,      // maxVisible
  theme   // SelectListTheme
);

list.onSelect = (item) => console.log("Selected:", item);
list.onCancel = () => console.log("Cancelled");
list.onSelectionChange = (item) => console.log("Highlighted:", item);
list.setFilter("opt"); // Filter items

Controls:

  • Mouse move/wheel: Highlight rows in alternate-screen mode
  • Click: Select a row
  • Arrow keys: Navigate
  • Enter: Select
  • Escape: Cancel

SettingsList

Settings panel with value cycling and submenus.

typescript
interface SettingItem {
  id: string;
  label: string;
  description?: string;
  currentValue: string;
  values?: string[];  // If provided, Enter/Space cycles through these
  submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
}

interface SettingsListTheme {
  label: (text: string, selected: boolean) => string;
  value: (text: string, selected: boolean) => string;
  description: (text: string) => string;
  cursor: string;
  hint: (text: string) => string;
}

const settings = new SettingsList(
  [
    { id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] },
    { id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector },
  ],
  10,      // maxVisible
  theme,   // SettingsListTheme
  (id, newValue) => console.log(`${id} changed to ${newValue}`),
  () => console.log("Cancelled")
);
settings.updateValue("theme", "light");

Controls:

  • Mouse move/wheel: Highlight rows in alternate-screen mode
  • Click: Activate a row
  • Arrow keys: Navigate
  • Enter/Space: Activate (cycle value or open submenu)
  • Escape: Cancel

Spacer

Empty lines for vertical spacing.

typescript
const spacer = new Spacer(2); // 2 empty lines (default: 1)

Image

Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals.

typescript
interface ImageTheme {
  fallbackColor: (str: string) => string;
}

interface ImageOptions {
  maxWidthCells?: number;
  maxHeightCells?: number;
  filename?: string;
}

const image = new Image(
  base64Data,       // base64-encoded image data
  "image/png",      // MIME type
  theme,            // ImageTheme
  options           // optional ImageOptions
);
tui.addChild(image);

Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.

Alternate-screen image compatibility

TuiAltScreen supports inline images and partial viewport cropping in terminals that implement the Kitty graphics protocol, including Kitty and Ghostty. iTerm2's inline-image protocol does not provide operations to delete an existing placement or crop its source while scrolling. To prevent stale images from remaining over repainted content, TuiAltScreen renders image components as text placeholders in iTerm2. TuiMainScreen continues to render iTerm2 inline images normally.

Autocomplete

CombinedAutocompleteProvider

Supports both slash commands and file paths.

typescript
import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui";

const provider = new CombinedAutocompleteProvider(
  [
    { name: "help", description: "Show help" },
    { name: "clear", description: "Clear screen" },
    { name: "delete", description: "Delete last message" },
  ],
  process.cwd() // base path for file completion
);

editor.setAutocompleteProvider(provider);

Features:

  • Type / to see slash commands
  • Press Tab for file path completion
  • Works with ~/, ./, ../, and @ prefix
  • Filters to attachable files for @ prefix

Key Detection

Use matchesKey() with the Key helper for detecting keyboard input (supports Kitty keyboard protocol):

typescript
import { matchesKey, Key } from "@earendil-works/pi-tui";

if (matchesKey(data, Key.ctrl("c"))) {
  process.exit(0);
}

if (matchesKey(data, Key.enter)) {
  submit();
} else if (matchesKey(data, Key.escape)) {
  cancel();
} else if (matchesKey(data, Key.up)) {
  moveUp();
}

Key identifiers (use Key.* for autocomplete, or string literals):

  • Basic keys: Key.enter, Key.escape, Key.tab, Key.space, Key.backspace, Key.delete, Key.home, Key.end
  • Arrow keys: Key.up, Key.down, Key.left, Key.right
  • With modifiers: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
  • String format also works: "enter", "ctrl+c", "shift+tab", "ctrl+shift+p"

Rendering modes

TuiMainScreen uses three rendering strategies:

  1. First Render: Output all lines without clearing scrollback
  2. Width Changed or Change Above Viewport: Clear screen and fully re-render
  3. Normal Update: Move the cursor to the first changed line, clear to the end, and render changed lines

TuiAltScreen owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With setLayoutRoot(), VStack, HStack, and nested ScrollView components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Scrollbars support hover expansion, thumb dragging, and track-click jumping. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless TuiAltScreenOptions.copyOnSelect is false, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.

Both renderers wrap updates in synchronized output (\x1b[?2026h ... \x1b[?2026l) for atomic, flicker-free rendering.

Terminal Interface

The TUI works with any object implementing the Terminal interface:

typescript
interface Terminal {
  start(onInput: (data: string) => void, onResize: () => void): void;
  stop(): void;
  write(data: string): void;
  get columns(): number;
  get rows(): number;
  moveBy(lines: number): void;
  hideCursor(): void;
  showCursor(): void;
  clearLine(): void;
  clearFromCursor(): void;
  clearScreen(): void;
}

Built-in implementations:

  • ProcessTerminal - Uses process.stdin/stdout
  • VirtualTerminal - For testing (uses @xterm/headless)

Utilities

typescript
import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";

// Get visible width of string (ignoring ANSI codes)
const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5

// Truncate string to width (preserving ANSI codes, adds ellipsis)
const truncated = truncateToWidth("Hello World", 8); // "Hello..."

// Truncate without ellipsis
const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo"

// Wrap text to width (preserving ANSI codes across line breaks)
const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20);
// ["This is a long line", "that needs wrapping"]

Creating Custom Components

When creating custom components, each line returned by render() must not exceed the width parameter. The TUI will error if any line is wider than the terminal.

Handling Input

Use matchesKey() with the Key helper for keyboard input:

typescript
import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
import type { Component } from "@earendil-works/pi-tui";

class MyInteractiveComponent implements Component {
  private selectedIndex = 0;
  private items = ["Option 1", "Option 2", "Option 3"];
  
  public onSelect?: (index: number) => void;
  public onCancel?: () => void;

  handleInput(data: string): void {
    if (matchesKey(data, Key.up)) {
      this.selectedIndex = Math.max(0, this.selectedIndex - 1);
    } else if (matchesKey(data, Key.down)) {
      this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
    } else if (matchesKey(data, Key.enter)) {
      this.onSelect?.(this.selectedIndex);
    } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
      this.onCancel?.();
    }
  }

  render(width: number): string[] {
    return this.items.map((item, i) => {
      const prefix = i === this.selectedIndex ? "> " : "  ";
      return truncateToWidth(prefix + item, width);
    });
  }

  invalidate(): void {}
}

Handling Line Width

Use the provided utilities to ensure lines fit:

typescript
import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
import type { Component } from "@earendil-works/pi-tui";

class MyComponent implements Component {
  private text: string;

  constructor(text: string) {
    this.text = text;
  }

  render(width: number): string[] {
    // Option 1: Truncate long lines
    return [truncateToWidth(this.text, width)];

    // Option 2: Check and pad to exact width
    const line = this.text;
    const visible = visibleWidth(line);
    if (visible > width) {
      return [truncateToWidth(line, width)];
    }
    // Pad to exact width (optional, for backgrounds)
    return [line + " ".repeat(width - visible)];
  }

  invalidate(): void {}
}

ANSI Code Considerations

Both visibleWidth() and truncateToWidth() correctly handle ANSI escape codes:

  • visibleWidth() ignores ANSI codes when calculating width
  • truncateToWidth() preserves ANSI codes and properly closes them when truncating
typescript
import chalk from "chalk";

const styled = chalk.red("Hello") + " " + chalk.blue("World");
const width = visibleWidth(styled); // 11 (not counting ANSI codes)
const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset

Caching

For performance, components should cache their rendered output and only re-render when necessary:

typescript
class CachedComponent implements Component {
  private text: string;
  private cachedWidth?: number;
  private cachedLines?: string[];

  render(width: number): string[] {
    if (this.cachedLines && this.cachedWidth === width) {
      return this.cachedLines;
    }

    const lines = [truncateToWidth(this.text, width)];

    this.cachedWidth = width;
    this.cachedLines = lines;
    return lines;
  }

  invalidate(): void {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
  }
}

Example

See test/chat-simple.ts for a complete chat interface example with:

  • Markdown messages with custom background colors
  • Loading spinner during responses
  • Editor with autocomplete and slash commands
  • Spacers between messages

Run it:

bash
node test/chat-simple.ts

Development

bash
# Install dependencies (from monorepo root)
npm install

# Run type checking
npm run check

# Run the demo
node test/chat-simple.ts

Debug logging

Set PI_TUI_WRITE_LOG to capture the raw ANSI stream written to stdout.

bash
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log node test/chat-simple.ts

Overview (`scripts/issues/README.md`)

Issue triage

A local tool for the daily issue review. It runs in this order:

  1. Sync. Keeps a local copy of the repository's issues in data/corpus.json with gh api. The first run fetches the last 120 days; after that it syncs every 20 minutes and only fetches what changed.
  2. Triage. Each new issue gets its own conversation with a pi-durable agent. The agent:
    • searches the local copy for duplicates and related issues (search_issues, read_issue)
    • picks a category from categories/*.md
    • writes a one-sentence summary and signals
    • suggests p0, p1, or none
  3. Review. A web UI lists the inbox: every issue created since the inbox start, grouped by the agent's suggestion. You set p0, p1, or none, and fix the category if needed. Each issue links to GitHub.

Decisions are stored locally only. Nothing is written to GitHub yet.

All state lives in data/ (gitignored):

  • corpus.json: the local copy of the issues
  • triage.sqlite: pi-durable storage with one conversation per triaged issue, the triage results, and your decisions in the triage.state document
  • token: the UI access token

Restarting the server resumes any triage runs that were in flight.

Files

  • categories/*.md: one file per category. The file name is the category label. The frontmatter owner is the GitHub user who owns the category.
  • prompt.md: the triage procedure and priority guidance given to the agent.
  • main.ts: sync loop and HTTP server.
  • triage.ts: the durable triage agent.
  • github.ts: the local copy of the issues and keyword search.
  • ui.html: the web UI.
  • source-hooks.mjs, register.mjs: run workspace packages from source, so no build is needed.

Run

bash
./scripts/issues/run.sh   # http://127.0.0.1:7788

Open http://127.0.0.1:7788/?t=<token> once, with the token from scripts/issues/data/token. The server then sets a cookie, so later visits don't need the token.

The agent authenticates through ~/.pi/agent/auth.json, the same credentials pi uses.

Variable Default
TRIAGE_MODEL openai-codex/gpt-5.6-sol provider/model. Supported providers: openai-codex, anthropic.
TRIAGE_THINKING medium Thinking level
TRIAGE_CONCURRENCY 4 Parallel triage runs
TRIAGE_INBOX_DAYS 2 On the first run, the inbox starts this many days back
TRIAGE_CORPUS_DAYS 120 On the first run, fetch issues updated in this many days
TRIAGE_SYNC_MINUTES 20 Sync interval
TRIAGE_PORT 7788 Local port
TRIAGE_SOCKET Listen on this unix socket instead of the port, for a reverse proxy
TRIAGE_DATA scripts/issues/data State directory

UI keys

  • j/k: move between issues
  • 0, 1, n: set p0, p1, none
  • o: open the issue on GitHub

Overview (`packages/coding-agent/examples/README.md`)

Examples

Example code for the pi-coding-agent SDK, process integration, and extensions.

CLI integration

rpc-client.ts uses the typed RpcClient to run Pi in a child process, stream events, and wait for the run to settle.

Build the coding-agent package before running it from a repository checkout:

bash
node examples/rpc-client.ts "Explain this repository"

Directories

[sdk/](sdk/)

Programmatic usage via createAgentSession(). Shows how to customize models, prompts, tools, extensions, and session management.

[extensions/](extensions/)

Example extensions demonstrating:

  • Lifecycle event handlers (tool interception, safety gates, context modifications)
  • Custom tools (todo lists, questions, subagents, output truncation)
  • Commands and keyboard shortcuts
  • Custom UI (footers, headers, editors, overlays)
  • Git integration (checkpoints, auto-commit)
  • System prompt modifications and custom compaction
  • External integrations (SSH, file watchers, system theme sync)
  • Custom providers (Anthropic with custom streaming, GitLab Duo)

Documentation


Overview (`packages/chord/src/delta/README.md`)

Chord Delta

Chord Delta produces immutable JSON revisions and exact operation batches for
ordered replicas. Import it from @earendil-works/chord/delta.

Immutability is an ownership contract. Nothing is frozen or defensively copied,
so an illegal mutation is not detected. It silently corrupts state.

ts
import { applyImmutable, applyImmutableBatches, track } from "@earendil-works/chord/delta";

const initial = { output: "", entries: [] as { id: number }[] };
const tracker = track(initial); // `initial` is transferred: never mutate it again

const change = tracker.beginChange();
change.state.output += "done\n"; // the draft is mutable only while the change is open
change.state.entries.push({ id: 1 }); // placed values are cloned; the caller keeps its object
const prepared = change.prepare(); // draft handles are unusable from here on

// tracker.value, prepared.base, prepared.value, and prepared.ops (including paths
// and payloads) are immutable by contract.
tracker.adopt(prepared); // tracker.value === prepared.value

// Shares containers with prepared.base and the op payloads: never mutate it either.
const replica = applyImmutable(prepared.base, prepared.ops);

Mutation rights

Value May it be mutated?
Root passed to track(), prepareReplace(), replicatedState(), or replace() No. Ownership moved to the tracker.
change.state and handles read from it Yes, only while that change is open. After prepare(), abort(), or another adoption, every use throws. Writes through a handle whose element was removed from the draft are ignored.
External value after assigning or inserting it into a draft Yes. The draft stored a validated clone.
tracker.value, prepared.base, retained older revisions No.
prepared.value, prepared.ops, op tuples, paths, permutations, payloads No. Payloads may be the same objects as parts of prepared.value.
applyImmutable() / applyImmutableBatches() inputs and result No. The result shares containers with both inputs.
Values from replicated state and cells (value, listener values, in-process handles) No. In-process consumers may share the provider's containers.
Mutable replica passed to apply() Only through apply(), with batches it owns exclusively. Code that edits it between batches breaks convergence.
Batch passed to apply() Consumed. apply() adopts payload containers into the replica. Use a detached copy for exactly one replica and never touch it again.

Replaying batches

Immutable replay is the preferred in-process path. One batch can fan out to any
number of immutable replicas:

ts
let replica = tracker.value; // shared with authority; never mutated
// For each adopted batch, in order:
replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.base

When only the final result of an ordered backlog is needed, replay its batches
without concatenating their operations. One copy-on-write scope is shared across
the complete call, so no intermediate revision is exposed or safe to retain:

ts
replica = applyImmutableBatches(
  replica,
  queuedFrames.map((frame) => frame.ops),
);

Use separate applyImmutable() calls when every intermediate revision is
published or retained.

Mutable replay needs a detached starting root and a detached copy of every batch
for every replica. Never apply one in-memory batch to two mutable replicas:

ts
import { apply } from "@earendil-works/chord/delta";

const detach = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;

let replica = detach(tracker.value); // exclusively owned starting root
// For each adopted batch, in order:
replica = apply(replica, detach(prepared.ops)); // fresh copy for this replica only
// Only apply() may change `replica`; do not edit it in application code.

Parsing a separately serialized message per replica is also a valid detachment.
decode() does not copy payloads, so decoding one in-memory WireOp[] for two
mutable replicas makes them alias each other.

Do and don't

  • Do build a fresh root and then transfer it; validate untrusted roots at your ingestion boundary.
  • Do keep structural sharing between revisions, for example replace(ctx, { ...state.value, changed }).
  • Do clone or serialize before handing any published value to code that may mutate it.
  • Do settle every change with prepare() or abort().
  • Don't reuse one container at two places in a transferred root.
  • Don't mutate anything the tracker owns, published, or received from a batch.
  • Don't keep draft handles past their change or place a settled handle.
  • Don't infer meaning from op shape. Only the resulting value is contractual.

Roots and placements

Full roots are trusted. track() and prepareReplace() take ownership in O(1)
and never walk the tree. Roots must be alias-free, acyclic strict JSON: dense
plain arrays, plain or null-prototype objects with enumerable own data
properties, strings, booleans, finite numbers, and null. Violations have
unspecified behavior. For example, a container shared by two keys can make
prepared.value diverge from what replicas compute from prepared.ops.

Draft placements are copied, and that copy walk also validates. Placements come
from property writes, index writes, push, unshift, splice, fill, and
copyWithin. A non-strict value throws TypeError before the draft changes;
for example, push(valid, invalid) inserts nothing. Rejected values include:
cycles, accessors (never invoked), symbol keys, class instances, sparse or
non-plain arrays, functions, bigint, NaN, infinities, and undefined.
Null-prototype objects are accepted and preserved. Placing a draft handle clones
its current content. Placing one value at several paths yields independent
containers.

undefined rules:

  • draft.obj.key = undefined deletes key.
  • arr[i] = undefined, push(undefined), unshift, splice, and fill with undefined throw.
  • undefined nested anywhere inside a placed object or array throws. It is not dropped like JSON.stringify does.

Arrays stay dense. Writing past the next index and deleting an element throw.
Growing length inserts null; shrinking it removes elements.

Lifecycle

tracker.value is the latest adopted revision. beginChange() opens an overlay
draft over it and never modifies it. A draft may stay open across await.
prepare() materializes the candidate and ops and does not change authority.
adopt() checks the preparation and swaps the root pointer.

Several changes may be open or prepared from one revision. Adopting one makes all
others stale: open drafts become unusable and their prepare() throws, and
prepared competitors are rejected by adopt(). adopt() also rejects foreign,
aborted, and already consumed preparations. Change.abort() or
Prepared.abort() after prepare() prevents adoption. The candidate stays
readable.

No-op batches:

  • Empty ops means prepared.value === prepared.base. Writes restored to their original value and deeply equal container assignments usually normalize to empty.
  • Equality ignores key order and prototype. A deeply equal assignment keeps the previous revision's order and prototype, even if the draft showed the new ones.
  • Structural array edits may emit a nonempty exact batch even when the result is deeply equal.
  • Adopting a no-op still advances tracker.revision and stales competitors. Replicated state does not publish no-ops.

prepareReplace(value) is a whole-root operation, not a diff. If value is
deeply equal to the current root, ops is empty and the current root is kept
(the comparison may traverse both trees). Otherwise ops is [["r", value]]
and prepared.value === value, so replicas receive the complete root.

Array mutators: push, pop, shift, unshift, splice, reverse, sort,
fill, and copyWithin. Held handles follow elements through reindexing.

Operations

Paths contain object keys and non-negative integer array indices.

Tuple Meaning
["r", value] Replace the complete value.
["s", path, value] Set an object property or array element.
["d", path] Delete an object property or remove an array element.
["a", path, text] Append to a string.
["t", path, count] Remove count UTF-16 code units from a string's front.
["p", path, index, remove, items] Splice an array.
["m", path, permutation] Reorder an array: new[i] = old[permutation[i]].

Batches are exact but not canonical. The same change may use different tuples,
and large edit sets may fold into a region splice, an ancestor s, or r.

Reserved keys: the tracker never emits __proto__, constructor, or
prototype as a path segment. A mutation at or below such a key is folded into a
set of the nearest safe ancestor, or r at the root. apply(), applyImmutable(), applyImmutableBatches(), and decoder() reject
those segments with UnsafePathError.
The appliers write values as own data properties, never through a prototype setter.
Appliers check op shape and path safety but not payload strictness. Chord's
remote state applies batches decoded from JSON frames, so their payloads are strict
JSON; validate payloads of batches from any other source.

encoder() interns repeated paths into WireOp tuples; decoder() validates
and restores Op tuples. Use one encoder/decoder pair per ordered state stream.
Path IDs span batches and an r resets both dictionaries. After a decode or
apply error, discard the decoder and replica and recover from a later r.

Limits and footguns

  • No freezing. Mutating any immutable value corrupts state silently.
  • In-process sharing: local handles, cell subscribers, and replicatedState subscribers receive the provider's containers. A consumer mutation corrupts authority and makes remote replicas diverge.
  • A change inside a large flat array copies that array's pointer storage for the new revision.
  • Placement validation costs time and memory on bulk inserts. In a benchmark unshifting 100k three-field objects, it added about 19 ms and 20 MiB of transient heap.
  • Draft reads go through proxies. In one measurement, 20,000 hot nested reads took about 9 ms through the tracker versus 0.08 ms on raw objects; compare with node --expose-gc packages/chord/test/delta-traversal.bench.ts --quick.
  • External proxies as placements, argument coercion callbacks that mutate the draft, non-primitive array indices, and non-numeric comparator results are out of contract. So are sort comparators that mutate, prepare, abort, or adopt. Behavior is unspecified.
  • Object identity is not replicated. Each path is an independent value placement.
  • One tracker is one revision sequence. Delivery order and persistence belong to the surrounding protocol.

Overview (`packages/codemode/examples/cloudflare/README.md`)

pi-codemode on Cloudflare

This example has two Workers:

  • sandbox/ deploys CodemodeSandboxDurableObject as its own Worker, pi-codemode-sandbox.
    Scripts run there, in a separate isolate.
  • agent/ has a Session Durable Object that owns the tools. It runs scripts through
    RemoteCodemodeSandbox, which keeps the tools and results in Session while the sandbox Worker
    runs the VM.

The README of the package explains why the
sandbox must be a separate Worker, and which limits apply.

Try it locally

Install @earendil-works/pi-codemode and wrangler in each directory, then start both Workers in
one process:

sh
npx wrangler dev -c agent/wrangler.jsonc -c sandbox/wrangler.jsonc
curl -X POST http://localhost:8787 --data 'const n = await tools.count({ add: 2 }); text("n=" + n); return n'

Deploy

Deploy the sandbox first, because the agent's binding refers to it by name:

sh
(cd sandbox && npx wrangler deploy)
(cd agent && npx wrangler deploy)

Overview (`packages/coding-agent/examples/extensions/README.md`)

Extension Examples

Example extensions for pi-coding-agent.

Usage

bash
# Load an extension with --extension flag
pi --extension examples/extensions/permission-gate.ts

# Or copy to extensions directory for auto-discovery
cp permission-gate.ts ~/.pi/agent/extensions/

Examples

Lifecycle & Safety

Extension Description
permission-gate.ts Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.)
project-trust.ts Demonstrates the project_trust event for user/global and CLI extensions
protected-paths.ts Blocks writes to protected paths (.env, .git/, node_modules/)
confirm-destructive.ts Confirms before destructive session actions (clear, switch, fork)
dirty-repo-guard.ts Prevents session changes with uncommitted git changes
sandbox/ OS-level sandboxing using @anthropic-ai/sandbox-runtime with per-project config
gondolin/ Route built-in tools and ! commands into a Gondolin micro-VM

Custom Tools

Extension Description
todo.ts Todo list tool + /todos command with custom rendering and state persistence
hello.ts Minimal custom tool example
question.ts Demonstrates ctx.ui.select() for asking the user questions with custom UI
questionnaire.ts Multi-question input with tab bar navigation between questions
tool-override.ts Override built-in tools (e.g., add logging/access control to read)
dynamic-tools.ts Register tools after startup (session_start) and at runtime via command, with prompt snippets and tool-specific prompt guidelines
structured-output.ts Final structured-output tool that returns terminate: true so the agent can end on the tool call
built-in-tool-renderer.ts Custom compact rendering for built-in tools (read, bash, edit, write) while keeping original behavior
minimal-mode.ts Override built-in tool rendering for minimal display (only tool calls, no output in collapsed mode)
truncated-tool.ts Wraps ripgrep with proper output truncation (50KB/2000 lines)
ssh.ts Delegate all tools to a remote machine via SSH using pluggable operations
subagent/ Delegate tasks to specialized subagents with isolated context windows

Commands & UI

Extension Description
preset.ts Named presets for model, thinking level, tools, and instructions via --preset flag and /preset command
plan-mode/ Claude Code-style plan mode for read-only exploration with /plan command and step tracking
tools.ts Interactive /tools command to enable/disable tools with session persistence
handoff.ts Transfer context to a new focused session via /handoff <goal>
qna.ts Extracts questions from last response into editor via ctx.ui.setEditorText()
status-line.ts Shows turn progress in footer via ctx.ui.setStatus() with themed colors
github-issue-autocomplete.ts Adds #1234 issue completions by stacking a custom autocomplete provider that preloads open issues from gh issue list
widget-placement.ts Shows widgets above and below the editor via ctx.ui.setWidget() placement
hidden-thinking-label.ts Customizes the collapsed thinking label via ctx.ui.setHiddenThinkingLabel()
working-indicator.ts Customizes the streaming working indicator via ctx.ui.setWorkingIndicator()
model-status.ts Shows model changes in status bar via model_select hook
snake.ts Snake game with custom UI, keyboard handling, and session persistence
tic-tac-toe.ts Tic-tac-toe vs the agent with executionMode: "sequential" tools to prevent race conditions on shared cursor state
send-user-message.ts Demonstrates pi.sendUserMessage() for sending user messages from extensions
timed-confirm.ts Demonstrates AbortSignal for auto-dismissing ctx.ui.confirm() and ctx.ui.select() dialogs
rpc-demo.ts Exercises all RPC-supported extension UI methods; pair with examples/rpc-extension-ui.ts
modal-editor.ts Custom vim-like modal editor via ctx.ui.setEditorComponent()
rainbow-editor.ts Animated rainbow text effect via custom editor
notify.ts Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm)
titlebar-spinner.ts Braille spinner animation in terminal title while the agent is working
summarize.ts Summarize conversation with GPT-5.2 and show in transient UI
custom-footer.ts Custom footer with git branch and token stats via ctx.ui.setFooter()
custom-header.ts Custom header via ctx.ui.setHeader()
overlay-test.ts Test overlay compositing with inline text inputs and edge cases
overlay-qa-tests.ts Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation
doom-overlay/ DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering)
shutdown-command.ts Adds /quit command demonstrating ctx.shutdown()
reload-runtime.ts Adds /reload-runtime and reload_runtime tool showing safe reload flow
interactive-shell.ts Run interactive commands (vim, htop) with full terminal via user_bash hook
inline-bash.ts Expands !{command} patterns in prompts via input event transformation
input-transform-streaming.ts Skips expensive input preprocessing for mid-stream steering via streamingBehavior

Git Integration

Extension Description
git-checkpoint.ts Creates git stash checkpoints at each turn for code restoration on fork
auto-commit-on-exit.ts Auto-commits on exit using last assistant message for commit message

System Prompt & Compaction

Extension Description
pirate.ts Demonstrates systemPromptAppend to dynamically modify system prompt
claude-rules.ts Scans .claude/rules/ folder and lists rules in system prompt
custom-compaction.ts Custom compaction that summarizes entire conversation
trigger-compact.ts Triggers compaction when context usage exceeds 100k tokens and adds /trigger-compact command

System Integration

Extension Description
mac-system-theme.ts Syncs pi theme with macOS dark/light mode

Resources

Extension Description
dynamic-resources/ Loads skills, prompts, and themes using resources_discover

Messages & Communication

Extension Description
message-renderer.ts Custom message rendering with colors and expandable details via registerMessageRenderer
entry-renderer.ts TUI-only session entry rendering via appendEntry and registerEntryRenderer
debug-provider.ts Toggle raw provider stream capture with /debug-provider and inspect each assistant message in a TUI-only session entry
event-bus.ts Inter-extension communication via pi.events

Session Metadata

Extension Description
session-name.ts Name sessions for the session selector via setSessionName
bookmark.ts Bookmark entries with labels for /tree navigation via setLabel

Custom Providers

Extension Description
custom-provider-anthropic/ Custom Anthropic provider with OAuth support and custom streaming implementation
custom-provider-gitlab-duo/ GitLab Duo provider using pi-ai's built-in Anthropic/OpenAI streaming via proxy
jev-router.ts Virtual model via registerVirtualModel that plans on Codex Sol or Terra (picked by the Jev classifier) and switches to Luna after the first edit

External Dependencies

Extension Description
with-deps/ Extension with its own package.json and dependencies (demonstrates jiti module resolution)
file-trigger.ts Watches a trigger file and injects contents into conversation

Writing Extensions

See docs/extensions.md for full documentation.

typescript
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function (pi: ExtensionAPI) {
  // Subscribe to lifecycle events
  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
      const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
      if (!ok) return { block: true, reason: "Blocked by user" };
    }
  });

  // Register custom tools
  pi.registerTool({
    name: "greet",
    label: "Greeting",
    description: "Generate a greeting",
    parameters: Type.Object({
      name: Type.String({ description: "Name to greet" }),
    }),
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      return {
        content: [{ type: "text", text: `Hello, ${params.name}!` }],
        details: {},
      };
    },
  });

  // Register commands
  pi.registerCommand("hello", {
    description: "Say hello",
    handler: async (args, ctx) => {
      ctx.ui.notify("Hello!", "info");
    },
  });
}

Key Patterns

Use StringEnum for string parameters (required for Google API compatibility):

typescript
import { StringEnum } from "@earendil-works/pi-ai";

// Good
action: StringEnum(["list", "add"] as const)

// Bad - doesn't work with Google
action: Type.Union([Type.Literal("list"), Type.Literal("add")])

State persistence via details:

typescript
// Store state in tool result details for proper forking support
return {
  content: [{ type: "text", text: "Done" }],
  details: { todos: [...todos], nextId },  // Persisted in session
};

// Reconstruct on session events
pi.on("session_start", async (_event, ctx) => {
  for (const entry of ctx.sessionManager.getBranch()) {
    if (entry.type === "message" && entry.message.toolName === "my_tool") {
      const details = entry.message.details;
      // Reconstruct state from details
    }
  }
});

Overview (`packages/coding-agent/examples/sdk/README.md`)

SDK Examples

Programmatic usage of pi-coding-agent via createAgentSession() and createAgentSessionRuntime().

The runtime example shows how to build a recreate function that closes over process-global fixed inputs and recreates cwd-bound services and sessions as the active session cwd changes.

Examples

File Description
01-minimal.ts Simplest usage with all defaults
02-custom-model.ts Select model and thinking level
03-custom-prompt.ts Replace or modify system prompt
04-skills.ts Discover, filter, or replace skills
05-tools.ts Built-in tool allowlists
06-extensions.ts Logging, blocking, result modification
07-context-files.ts AGENTS.md context files
08-prompt-templates.ts File-based prompt templates
09-api-keys-and-oauth.ts API key resolution, OAuth config
10-settings.ts Override compaction, retry, terminal settings
11-sessions.ts In-memory, persistent, continue, list sessions
12-full-control.ts Replace everything, no discovery
13-session-runtime.ts Manage runtime-backed session replacement
14-codemode-mcp.ts Add the codemode, tool_search, and MCP extensions

Running

bash
cd packages/coding-agent
node examples/sdk/01-minimal.ts

Quick Reference

text
/* Detailed source-code truncated for AI context efficiency. */

Options

Option Default Description
modelRuntime Runtime using agentDir/auth.json and models.json Canonical model and authentication runtime
cwd process.cwd() Working directory
agentDir ~/.pi/agent Config directory
model From settings/first available Model to use
thinkingLevel From settings/"off" off, low, medium, high
tools ["read", "bash", "edit", "write"] built-ins Allowlist tool names across built-in, extension, and custom tools
customTools [] Additional tool definitions
resourceLoader DefaultResourceLoader Resource loader for extensions, skills, prompts, themes, and context files
sessionManager SessionManager.create(cwd) Persistence
settingsManager SettingsManager.create(cwd, agentDir) Settings overrides

Events

typescript
session.subscribe((event) => {
  switch (event.type) {
    case "message_update":
      if (event.assistantMessageEvent.type === "text_delta") {
        process.stdout.write(event.assistantMessageEvent.delta);
      }
      break;
    case "tool_execution_start":
      console.log(`Tool: ${event.toolName}`);
      break;
    case "tool_execution_end":
      console.log(`Result: ${event.result}`);
      break;
    case "agent_settled":
      console.log("Done");
      break;
  }
});

Overview (`packages/coding-agent/test/mcp-conformance/README.md`)

MCP client conformance

Runs the official MCP conformance suite against pi's MCP client and compares the result with a committed baseline.

bash
npm run test:mcp-conformance

CI runs it in the mcp-conformance job of .github/workflows/ci.yml. Requires network access the first time, to fetch the pinned suite (@modelcontextprotocol/[email protected]) with npx. Install scripts are disabled. Needs a POSIX shell.

How it works

For every protocol version pi negotiates (2025-03-26, 2025-06-18, 2025-11-25), run.ts lists the suite's client scenarios and runs them one at a time. The suite starts a scenario server and runs client.ts against it.

client.ts uses the code pi runs for an HTTP server in mcp.json: McpServerConnection connects and calls tools, and signInMcpServer runs the OAuth sign-in that /mcp starts. A simulated browser fetches the authorization URL and delivers the redirect to pi's loopback callback server. When the server asks for sign-in again (for example for more scope), the simulated user signs in again, up to three times.

Every check the suite reports is compared with baseline.json. The extra pi-client check records whether client.ts completed the scenario. Some scenarios expect the client to give up (auth/scope-retry-limit), so it is baselined like the others.

The run fails when:

  • a check that passes in the baseline fails or is missing,
  • a check fails that the baseline does not list as failing,
  • a baselined scenario did not run.

Checks that started passing are reported; update the baseline to lock them in.

Options

bash
node packages/coding-agent/test/mcp-conformance/run.ts --mode 2025-11-25 --scenario auth/scope-step-up --verbose
node packages/coding-agent/test/mcp-conformance/run.ts --update-baseline
  • --mode, --scenario: run a subset (repeatable).
  • --verbose: print the suite's output, including each check and the client's log.
  • --keep-results: keep the suite's checks.json and client output.
  • --update-baseline: write the results of a full run to baseline.json. Review the diff; do not regenerate it to hide a regression.

Known failures

  • elicitation-sep1034-client-defaults: pi does not support elicitation.
  • auth/basic-cimd: pi registers clients dynamically instead of using a Client ID Metadata Document.
  • auth/scope-retry-limit: pi-client fails by design; the server never accepts the granted scope.

2026-07-28 is not covered: it is a stateless protocol pi does not implement.


Overview (`packages/coding-agent/test/suite/README.md`)

Coding agent suite tests

Use test/suite/ for the new harness-based test suite around AgentSession and AgentSessionRuntime.

Rules:

  • Use test/suite/harness.ts
  • Use the faux provider from packages/ai/src/providers/faux.ts
  • Do not use real provider APIs, real API keys, network calls, or paid tokens
  • Keep these tests CI-safe and deterministic
  • Do not use or extend the legacy test/test-harness.ts path unless a missing capability forces it

Overview (`packages/tui/native/darwin/README.md`)

Darwin native prebuilds

Provides modifier-key state and asynchronous text, image, and file-path clipboard access using AppKit.

Building

On macOS, run from the repository root:

sh
npm --prefix packages/tui run build:native:darwin

The build uses Apple clang and the macOS SDK found through xcrun. Either Intel or Apple Silicon can build both targets: arm64 (macOS 11+) and x64 (macOS 10.15+).

Cross-building requires a Darwin toolchain with a macOS SDK and Mach-O linker, such as osxcross:

sh
CC=/path/to/osxcross/clang SDKROOT=/path/to/MacOSX.sdk \
  npm --prefix packages/tui run build:native:darwin

The SDK must be obtained and used under Apple's license. Plain clang or Zig alone is not sufficient.


Overview (`packages/tui/native/linux/README.md`)

Linux clipboard helper

Provides asynchronous X11 text and image reads using libxcb.so.1. Prebuilds support x64 and arm64 on glibc and musl. Reads have bounded waits; if a native operation stalls, the helper remains unavailable until it finishes.

Coding-agent falls back to command-line tools when native reads are unavailable. Wayland reads use wl-paste; all Linux writes use the existing command-line or terminal clipboard paths.

Building

Install a C compiler and XCB development headers, then run from the repository root on each supported Linux architecture:

sh
npm --prefix packages/tui run build:native:linux

Testing

Install the build dependencies plus pkg-config, Xvfb, and xclip, then run from packages/tui:

sh
node --test test/native-clipboard-linux.test.ts

Tests use isolated X11 servers, not the desktop clipboard. They skip when dependencies are missing.


Overview (`packages/tui/native/win32/README.md`)

Windows native prebuilds

Provides console input setup, modifier-key state, and asynchronous text/image clipboard access. Links to kernel32 and user32; no Node headers are required.

Building

On Windows, install Visual Studio's "Desktop development with C++" workload, then run from the repository root to build x64 and arm64:

sh
npm --prefix packages/tui run build:native:win32

For cross-builds or custom toolchains, provide MinGW-compatible compilers:

sh
PI_TUI_WIN32_TOOLCHAIN=mingw \
CC_X64=/path/to/x86_64-w64-mingw32-gcc \
CC_ARM64=/path/to/aarch64-w64-mingw32-gcc \
npm --prefix packages/tui run build:native:win32

Testing clipboard writes

On a Windows test desktop, run from packages/tui in PowerShell:

powershell
$env:PI_TEST_NATIVE_CLIPBOARD = "1"
node --test test/native-platform.test.ts

This opt-in test checks native text writes and reads. It replaces the system clipboard contents.


Overview (`packages/coding-agent/examples/extensions/doom-overlay/README.md`)

DOOM Overlay Demo

Play DOOM as an overlay in pi. Demonstrates that the overlay system can handle real-time game rendering at 35 FPS.

Usage

bash
pi --extension ./examples/extensions/doom-overlay

Then run:

text
/doom-overlay

The shareware WAD file (~4MB) is auto-downloaded on first run.

Controls

Action Keys
Move WASD or Arrow Keys
Run Shift + WASD
Fire F or Ctrl
Use/Open Space
Weapons 1-7
Map Tab
Menu Escape
Pause/Quit Q

How It Works

DOOM runs as WebAssembly compiled from doomgeneric. Each frame is rendered using half-block characters (▀) with 24-bit color, where the top pixel is the foreground color and the bottom pixel is the background color.

The overlay uses:

  • width: "90%" - 90% of terminal width
  • maxHeight: "80%" - Maximum 80% of terminal height
  • anchor: "center" - Centered in terminal

Height is calculated from width to maintain DOOM's 3.2:1 aspect ratio (accounting for half-block rendering).

Credits


Overview (`packages/coding-agent/examples/extensions/plan-mode/README.md`)

Plan Mode Extension

Read-only exploration mode for safe code analysis.

Features

  • Built-in write tools disabled: Disables edit/write while preserving other active tools
  • Bash allowlist: Only read-only bash commands are allowed
  • Plan extraction: Extracts numbered steps from Plan: sections
  • Progress tracking: Widget shows completion status during execution
  • [DONE:n] markers: Explicit step completion tracking
  • Session persistence: State survives session resume

Commands

  • /plan - Toggle plan mode
  • /todos - Show current plan progress
  • Ctrl+Alt+P - Toggle plan mode (shortcut)

Usage

  1. Enable plan mode with /plan or --plan flag
  2. Ask the agent to analyze code and create a plan
  3. The agent should output a numbered plan under a Plan: header:
text
Plan:
1. First step description
2. Second step description
3. Third step description
  1. Choose "Execute the plan" when prompted
  2. During execution, the agent marks steps complete with [DONE:n] tags
  3. Progress widget shows completion status

How It Works

Plan Mode (Read-Only)

  • Built-in edit/write tools disabled
  • Other active tools remain available
  • Bash commands filtered through allowlist
  • Agent creates a plan without making changes

Execution Mode

  • Full tool access restored
  • Agent executes steps in order
  • [DONE:n] markers track completion
  • Widget shows progress

Command Allowlist

Safe commands (allowed):

  • File inspection: cat, head, tail, less, more
  • Search: grep, find, rg, fd
  • Directory: ls, pwd, tree
  • Git read: git status, git log, git diff, git branch
  • Package info: npm list, npm outdated, yarn info
  • System info: uname, whoami, date, uptime

Blocked commands:

  • File modification: rm, mv, cp, mkdir, touch
  • Git write: git add, git commit, git push
  • Package install: npm install, yarn add, pip install
  • System: sudo, kill, reboot
  • Editors: vim, nano, code

Overview (`packages/coding-agent/examples/extensions/subagent/README.md`)

Subagent Example

Delegate tasks to specialized subagents with isolated context windows.

Features

  • Isolated context: Each subagent runs in a separate pi process
  • Streaming output: See tool calls and progress as they happen
  • Parallel streaming: All parallel tasks stream updates simultaneously
  • Markdown rendering: Final output rendered with proper formatting (expanded view)
  • Usage tracking: Shows turns, tokens, cost, and context usage per agent
  • Abort support: Ctrl+C propagates to kill subagent processes

Structure

text
subagent/
├── README.md            # This file
├── index.ts             # The extension (entry point)
├── agents.ts            # Agent discovery logic
├── agents/              # Sample agent definitions
│   ├── scout.md         # Fast recon, returns compressed context
│   ├── planner.md       # Creates implementation plans
│   ├── reviewer.md      # Code review
│   └── worker.md        # General-purpose (full capabilities)
└── prompts/             # Workflow presets (prompt templates)
    ├── implement.md     # scout -> planner -> worker
    ├── scout-and-plan.md    # scout -> planner (no implementation)
    └── implement-and-review.md  # worker -> reviewer -> worker

Installation

From the repository root, symlink the files:

bash
# Symlink the extension (must be in a subdirectory with index.ts)
mkdir -p ~/.pi/agent/extensions/subagent
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts

# Symlink agents
mkdir -p ~/.pi/agent/agents
for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do
  ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f")
done

# Symlink workflow prompts
mkdir -p ~/.pi/agent/prompts
for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do
  ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f")
done

Security Model

This tool executes a separate pi subprocess with a delegated system prompt and tool/model configuration.

Project-local agents (.pi/agents/*.md) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc.

Default behavior: Only loads user-level agents from ~/.pi/agent/agents.

To enable project-local agents, pass agentScope: "both" (or "project"). Only do this for repositories you trust.

When running interactively, the tool prompts for confirmation before running project-local agents in untrusted projects. Trusted projects skip the additional prompt. Set confirmProjectAgents: false to disable confirmation.

Usage

Single agent

text
Use scout to find all authentication code

Parallel execution

text
Run 2 scouts in parallel: one to find models, one to find providers

Chained workflow

text
Use a chain: first have scout find the read tool, then have planner suggest improvements

Workflow prompts

text
/implement add Redis caching to the session store
/scout-and-plan refactor auth to support OAuth
/implement-and-review add input validation to API endpoints

Tool Modes

Mode Parameter Description
Single { agent, task } One agent, one task
Parallel { tasks: [...] } Multiple agents run concurrently (max 8, 4 concurrent)
Chain { chain: [...] } Sequential with {previous} placeholder

Output Display

Collapsed view (default):

  • Status icon (✓/✗/⏳) and agent name
  • Last 5-10 items (tool calls and text)
  • Usage stats: 3 turns ↑input ↓output RcacheRead WcacheWrite $cost ctx:contextTokens model

Expanded view (Ctrl+O):

  • Full task text
  • All tool calls with formatted arguments
  • Final output rendered as Markdown
  • Per-task usage (for chain/parallel)

Parallel mode streaming:

  • Shows all tasks with live status (⏳ running, ✓ done, ✗ failed)
  • Updates as each task makes progress
  • Shows "2/3 done, 1 running" status
  • Returns each completed task's final output to the parent model, capped at 50 KB per task
  • Returns failure diagnostics from stderr/error messages when a child exits before producing output

Tool call formatting (mimics built-in tools):

  • $ command for bash
  • read ~/path:1-10 for read
  • grep /pattern/ in ~/path for grep
  • etc.

Agent Definitions

Agents are markdown files with YAML frontmatter:

markdown
---
name: my-agent
description: What this agent does
tools: read, grep, find, ls
model: claude-haiku-4-5
---

System prompt for the agent goes here.

When model is omitted, the subagent inherits the dispatching session's active model and thinking level.

Locations:

  • ~/.pi/agent/agents/*.md - User-level (always loaded)
  • .pi/agents/*.md - Project-level (only with agentScope: "project" or "both")

Project agents override user agents with the same name when agentScope: "both".

Sample Agents

Agent Purpose Model Tools
scout Fast codebase recon Haiku read, grep, find, ls, bash
planner Implementation plans Sonnet read, grep, find, ls
reviewer Code review Sonnet read, grep, find, ls, bash
worker General-purpose Sonnet (all default)

Workflow Prompts

Prompt Flow
/implement <query> scout → planner → worker
/scout-and-plan <query> scout → planner
/implement-and-review <query> worker → reviewer → worker

Error Handling

  • Exit code != 0: Tool returns error with stderr/output
  • stopReason "error": LLM error propagated with error message
  • stopReason "aborted": User abort (Ctrl+C) kills subprocess, throws error
  • Chain mode: Stops at first failing step, reports which step failed

Limitations

  • Output truncated to last 10 items in collapsed view (expand to see all)
  • Parallel model-visible output is capped at 50 KB per task; full results remain in tool details
  • Agents discovered fresh on each invocation (allows editing mid-session)
  • Parallel mode limited to 8 tasks, 4 concurrent

Packages Coding Agent Docs Index (`packages/coding-agent/docs/index.md`)

Pi

Pi is an extensible AI agent that works from your terminal. Give it a goal and a working folder, and it can inspect files, run commands, edit content, and work through multi-step tasks.

Use Pi for software development, research notes, writing projects, data files, or hobby work. You can use Pi as is, prompt it to adapt itself to your workflow, or build other applications powered by Pi using the SDK.

Start using Pi

New to Pi? Follow the Quickstart to install Pi, connect a model, and complete your first task.

If Pi is already installed, choose what you want to do:

Customize Pi

Pi can reuse prompts, load specialized instructions, add executable integrations, change its terminal interface, connect model services, and distribute these resources as packages.
Use the Quickstart customization chooser to select the smallest mechanism that meets your need.

Automate or embed Pi

Find reference and setup information

Use the reference pages to look up CLI options, settings, providers, keybindings, and environment variables.

For platform-specific help, see Terminal Setup, Windows, tmux, Termux on Android, or Containerization.

Work safely

Pi's tools and extensions run with the permissions of the Pi process. Project trust controls which project resources Pi loads, but it does not sandbox tool calls. Review Security before using untrusted files, repositories, extensions, or unattended automation.


Packages Coding Agent Docs Quickstart (`packages/coding-agent/docs/quickstart.md`)

Quickstart

Pi runs in your terminal and works with files on your machine. To use it, you need access to a model through a supported provider. This can be a subscription, an API key, or a local model.

For native Windows setup, read Windows Setup. For Android, read Termux Setup.

1. Install Pi

On macOS or Linux, you can use the installer:

bash
curl -fsSL https://pi.dev/install.sh | sh

The installer pins all dependencies and updates Pi with pi update. Alternatively, install Pi from npm, which does not pin transitive dependencies. This requires Node.js 22.19 or newer:

bash
npm install --ignore-scripts @earendil-works/pi-coding-agent

Pi does not require dependency lifecycle scripts for a normal npm installation.

With Nix on macOS or Linux, install the latest release from Pi's flake. Nix builds Pi from source:

bash
nix profile add github:earendil-works/pi/stable

Older Nix versions use nix profile install instead. Update with nix profile upgrade pi; pi update cannot update a Nix installation. To pin a release, use a tag such as github:earendil-works/pi/v1.0.0.

Verify the installation:

bash
pi --version

2. Start Pi

Change to the folder you want Pi to work with, then start it:

bash
cd /path/to/folder
pi

The working folder helps Pi discover relevant files, instructions, and configuration. Pi also uses it to group saved sessions.

The interface shows your conversation, an editor for prompts and commands, and a footer with the current folder, model, and session status. See Use Pi in the terminal to learn how to add files, run commands, direct ongoing work, and manage results.

3. Choose a model

A model generates Pi's responses. A provider is the service or account Pi uses to access that model.

In Pi, run:

text
/login

Choose a provider, then follow the prompts to use a subscription or store an API key. Run /model afterward if you want to select a different available model.

See Choose a model and provider for supported providers, environment-variable authentication, local models, and custom endpoints.

4. Give Pi a task

Pi shows each file read, search, command, and edit it performs. It does not ask before every tool call.

Enter a task that matches your work, for example:

text
Summarize @meeting-notes.md and save the action items to action-items.md.
text
Explain how this repository is structured and how to run its checks.
text
Compare @previous.csv with @current.csv and summarize the important changes.

Type @ in the editor to search for a file instead of entering its full path. When Pi finishes, review its response and any changed files. Use version control or backups for important work. For untrusted or unattended work, use a container or another sandbox. See Security.

Continue later

Pi saves sessions automatically. Exit Pi, then resume the most recent session for the same working folder with:

bash
pi --continue

Use /resume to choose another saved session. See Continue or branch a session for session naming, branching, compaction, export, and sharing.

Next steps

Choose how to customize Pi

Start with the least powerful mechanism that meets your need:

Need Start with
Give Pi persistent instructions for a folder AGENTS.md
Reuse a prompt from the / menu Prompt template
Add task-specific instructions and supporting files Skill
Add executable tools, commands, or event handlers Extension
Build a custom terminal component Terminal UI
Connect an unsupported model service Custom provider
Install or distribute several resources Pi package

Uninstall Pi

If you installed Pi with npm, run:

bash
npm uninstall -g @earendil-works/pi-coding-agent

If you used the installer, run it again and choose Uninstall Pi:

bash
curl -fsSL https://pi.dev/install.sh | sh

If you installed Pi with Nix, run:

bash
nix profile remove pi

None of these methods removes configuration, credentials, sessions, or installed Pi packages from ~/.pi/agent/.


Packages Chord Docs Guide (`packages/chord/docs/guide.md`)

Chord usage guide

Describes the runtime in src. The specification defines its semantics and lists known limitations.

Chord builds applications out of objects that live in several processes (a server, workers, browser tabs) and can be hot reloaded while they run.

  • An object type is declared by a contract: a name, an input (the object's address) and members. A plugin's
    facet (its code for one host: server, worker, browser) serves object types.
  • An object is one instance of an object type: state plus code at an address. It is created on demand and lives
    while someone references it.
  • Everyone gets a reference, never the object. References work the same locally and across processes, and survive reloads.
  • Every object knows its principal: an owner and a guest get different objects, with different methods.
  • State is observable JSON. Remote viewers get a snapshot followed by deltas.
  • Hot reload swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.

Contents

Quick start

ts
import { Type } from "typebox";
import { createHost, method, remote } from "@earendil-works/chord";

// contract.ts: shared by provider and consumers
export const Counter = remote("demo.counter", {
	version: 1,
	input: Type.Object({ name: Type.String() }),
	variants: {
		default: {
			state: { count: Type.Number() },
			methods: { increment: method(Type.Object({ by: Type.Number() })) },
		},
	},
});

const host = createHost({ name: "server", self: { kind: "server" } });

host.serve(Counter, ({ name }, _principal, self) => {
	const count = self.state("count", Type.Number(), 0);
	return {
		variant: "default",
		count,
		increment: async ({ by }, ctx) => {
			count.change(ctx, (n) => n + by);
		},
	};
});

await host.root(async (ctx) => {
	const clicks = await ctx.get(Counter, { name: "clicks" });  // ready, with its state; released when this root ends
	clicks.count.subscribe((n) => console.log("count", n));
	await clicks.increment({ by: 2 }, ctx);  // logs 0, then 2
});

Contracts

A contract is a runtime value: a name, a version number, and a description of the members. Shipped bundles have no TypeScript, so everything the runtime checks must be in the contract. TypeScript types are inferred from it.

Remote types

Remote types can be reached from other hosts. Everything that crosses the wire has a schema, and the provider validates it.

ts
export const Chat = remote("pi.chat", {
	version: 1,
	input: Type.Object({ session: Type.String(), conversation: Type.Number() }),
	host: hostFamily("session:", "session"),  // lives on `session:S1` for `{ session: "S1", … }`
	variants: {
		viewer: {
			state: { view: ConversationViewSchema },
		},
		participant: {
			state: { view: ConversationViewSchema },
			methods: {
				send: method(SendInputSchema, Type.Object({ submissionId: Type.Number() })),
				stop: method(),  // no arguments, no result: `chat.stop(ctx)`
			},
		},
	},
});

Where a remote type lives is part of its contract, so no caller names a host:

  • host: hostNamed("server"): every object lives on the host named server.
  • host: hostFamily("session:", "session"): an object lives on session: + its input's session field, a required
    string. TypeScript checks the field name against the input.

A get normalizes the input (defaults count), resolves the host, and acquires the object here if that is this host,
otherwise over the link to it. serve refuses a type whose placement does not include the serving host, so a misplaced
type fails at startup or at the reload that adds it. The host that gets a hold checks that its own contract places the
input on itself, and a relay checks the hold's target against its contract token: a client never chooses where an
object lives. The placement is part of the description and the fingerprint: moving a type is a breaking change, and
references to it get upgrade_required.

Local types

Local types are reachable only inside their host. Their members may be anything: functions, callbacks, native objects. Non-JSON members get opaque or function descriptors.

ts
export const Index = local("pi.index", {
	version: 1,
	input: Type.Object({ session: Type.String() }),
	members: {
		search: method(Type.Object({ query: Type.String() }), Type.Array(HitSchema)),
		onChange: method(Type.Function([ChangeSchema], Type.Void()), UnsubscribeSchema),
		harness: opaque("pi.durable-harness@1"),
	},
});

Inputs identify objects

{ session: "S1", conversation: 1 } always means the same object. Inputs are normalized (defaults applied, unknown fields removed), validated, and serialized canonically. Fields equal to their default are left out, so adding a new optional field with a default doesn't change existing addresses. Changing a default, or how inputs are normalized, needs a new type name.

Versions and compatibility

Every bundle carries its own copy of each contract it was built against. When a consumer binds to a provider, the two copies are compared:

A consumer built against version M binds to a provider at version N if N = M and the provider's description covers the consumer's. Otherwise the provider must offer an adapter in accepts[M], or binding fails with contract_mismatch.

"Covers" means the provider still offers everything the consumer was built against:

  • every member exists with the same kind;
  • methods accept everything the consumer can send (new arguments must be optional) and return at least what it expects (extra result fields are fine);
  • state values fit the consumer's schema;
  • opaque members have the same name and version.
Change Version Result
Add a method, an optional argument, a result field, a state field keep binds
Remove or rename a member; make an argument required; change a type bump old consumers need accepts, or must be reloaded too
Same shape, different meaning (query is now a regular expression) bump as above; undetectable, so it's on you
Forgot to bump on a breaking shape change keep refused: "consumer needs member search"
ts
// version 2 changed what `search` means; old consumers keep working through an adapter
host.serve(Index, indexFactory, {
	accepts: {
		1: (current) => ({ search: ({ query }, ctx) => current.search({ query: escapeRegExp(query) }, ctx) }),
	},
});

Serving objects

ts
host.serve(Type, (input, principal, self) => implementation, options);
  • The factory runs synchronously. It declares what the object needs and returns its implementation: methods and state members, plus variant for remote types. Returning a promise throws.
  • There is one object per (type, input, principal). It holds the object's state, resources and references, and survives reloads. Only the implementation is replaced.
  • Options:
    • accepts: adapters for consumers built against older contract versions;
    • lifetime: "referenced" (default) creates the object at its first get and disposes it after the last reference
      is released; "host" creates it when the plugin loads and keeps it until the host closes (see Lifetime);
    • linger: how long to keep a referenced object after the last reference is released.

Declarations

A reload runs the factory again over the same object. Declarations that keep something across a reload have a name: the new code's status cell is the old code's status cell, with its value. Derivations and tasks hold nothing across a reload and have no name.

ts
host.serve(StatusStore, ({ session }, _host, self) => {
	const status = self.state("status", StatusSchema, { running: false, tasks: 0 });
	const errors = self.state("errors", Type.Array(Type.String()), []);
	const catalog = self.get(Catalog, {});
	const db = self.resource("db", {
		version: 1,
		open: async ({ signal }) => openSqlite(statusPath(session), { signal }),
		close: async (db) => db.close(),
	});
	self.on("catalogChanges", catalog.rows, (rows, ctx) => errors.replace(ctx, checkRows(rows)));
	self.task(async (ctx) => {
		while (!ctx.signal.aborted) {
			await sleep(10_000, ctx.signal);
			await db.value.run("INSERT INTO history VALUES (?)", [JSON.stringify(status.value)]);
		}
	});
	const summary = self.derive([status, errors], (s, e) => ({ ...s, failing: e.length > 0 }));
	return { summary, report: reportMethod(status) };  // exposed state is what the implementation returns
});
Declaration What it is On reload
self.state(name, schema, initial | { init }, { version?, migrate? }) an observable JSON cell; the schema is required same cell and value (init does not run); migrated when its version rises
self.get(Type, input, { at? }) a reference to another object; its identity is the address kept if declared again, otherwise released
self.resource(name, { version, deps?, exclusive?, open, close? }) a connection, watch, socket, port kept; reopened only if version, deps or exclusive change
self.on(name, source, handler) or self.on(name, source, select, handler) reacts to state or to an event source the same subscription, with the new handler, as long as the source and select are unchanged
self.task(async (ctx) => ...) long-running background work the old run is aborted through ctx.signal; the new code starts once it finished (at most taskStopDeadline, default 5 s)
self.derive(sources, fn) live projection of state; fn is synchronous a new derivation, computed when the reload commits
self.register(Registry, { key: value }) an entry in a registry replaced at commit
self.expose(resource) a member backed by a resource (local types) follows the resource

Names are strings: declaring one twice in a run fails (invalid/duplicate). Each kind has its own names, so a cell and a resource may share one.

A listener keeps its subscription across reloads when it is declared on the same resource (or source) with the same select: the same function, or one with the same source text. select is not called again, so a source that accepts a single subscriber (a Durable watch) survives reloads, and select may wrap the value in a new adapter object. A select whose result depends on captured values that change between reloads is not noticed; give such a listener a new name.

A listener on state reconciles. A listener on state (a cell, a derivation, a reference's state member) gets the current value when it starts, then every new value. When a reload replaces its source (a derivation is new at every reload), it subscribes again and gets the current value again. Write its handler to bring things in line with the value it gets (set, compare, start or stop what the value says), not to count or append per call. An event source (a resource's events) delivers no current value.

A name you no longer declare is released, closed or stopped, after the old code has finished what it was doing.

State that must survive a reload lives in cells, never in closure variables. A closure variable lasts as long as one version of the code.

ts
// wrong: reset on every reload
host.serve(Visits, (_, _p, self) => {
	let visits = 0;
	return { hit: async (ctx) => ++visits };
});

// right
host.serve(Visits, (_, _p, self) => {
	const visits = self.state("visits", Type.Number(), 0);
	return {
		hit: async (ctx) => {
			visits.change(ctx, (n) => n + 1);
			return visits.value;
		},
	};
});

Methods are declared with method(args?, result?) in the contract. Without args a method takes none: callers write ref.hit(ctx) (or ref.hit()), and its implementation is (ctx) => …. Without result it returns nothing: the implementation returns nothing, and callers get undefined. method(undefined, Type.Number()) takes no arguments and returns a number.

Activation

The factory is synchronous, but activation isn't. self.get and self.resources only declare; the slow work happens afterwards:

text
get(Index, { session: "S1" })
 1. run the factory               → declarations
 2. open declared resources        → e.g. connect to SQLite
 3. wait for declared refs        → dependencies activate first
 4. run the cells' init           → e.g. read the rows into a cell
 5. live                           → refs become "live"; listeners and tasks start
ts
host.serve(Index, ({ session }, _host, self) => {
	const db = self.resource("db", { version: 1, open: () => openSqlite(indexPath(session)), close: (db) => db.close() });
	return {
		search: async ({ query }, ctx) => db.value.all("SELECT entryId, text FROM fts WHERE fts MATCH ?", [query]),
	};
});

host.serve(Chat, ({ session, conversation }, principal, self) => {
	const index = self.get(Index, { session });  // a placeholder until Index is live
	return {
		variant: "participant",
		find: async ({ query }, ctx) => index.search({ query }, ctx),  // only ever runs once Chat (and Index) are live
		// ...
	};
});

Rules:

  • Inside the factory, never use what you declared: db.value and index.someState.value throw not_ready, and calling a reference throws. Use them in methods, listeners, tasks and init.
  • The object goes live only with its references ready: live, and (for a remote reference) every state member of its variant hydrated. The same holds for state it forwards or derives from a remote reference it holds some other way: it goes live once that state has its value. A reference that is not ready after the host's readyTimeout (default 60 s) fails the activation with unavailable/ready_timeout; a reference cycle across hosts ends that way instead of hanging.
  • Concurrent gets of one address share one activation, so the database opens once.
  • Failure propagates: if openSqlite throws, Index fails, Chat fails with that cause, and every waiting get rejects. The next get tries again.
  • Abandoned activations: if everyone releases before activation finishes, the result is disposed.

Startup readiness and parent admission

await host.load(facets) and await host.install(facet) wait for the lifetime: "host" objects they install to
become live: resources are open, declared references are live, and registrations are published. All facets in a load
are installed before activation is awaited, so a host object can use a provider in a later facet. Referenced objects
are created only when something references them. Factory, dependency and resource errors reject startup with the
reference's activation error; host object failures also reach onError. Installation is not rolled back on failure: close the host
when abandoning startup. install registers synchronously, so setup/registration errors still throw synchronously.

await host.ready() waits for the host objects present when called, including direct
host.serve(..., { lifetime: "host" }) and previously deferred loads. It is an activation barrier, not a continuous health check, a wait for tasks to
finish. Later installations require another readiness wait.

A worker may need to open its local Harness and announce control readiness before the parent accepts its link.
A host object reporting to the server's SessionStatus cannot become live before that acceptance. Awaiting the
whole load before announcing control readiness would deadlock. Defer the whole barrier explicitly:

ts
// Connect the upstream link first; its outgoing references can queue until the parent accepts it.
await worker.load(workerFacets, { awaitHostObjects: false });  // also supported by install
const harness = await worker.get(Harness, {});        // local bootstrap only; no upstream dependency
announceControlReady();                               // parent can now accept the worker link
await worker.ready();                                 // every host object live, including upstream references
harness.release();

awaitHostObjects: false still starts every host object; it skips waiting, not activation. There is no
automatic classification of upstream dependencies. Choose a local bootstrap reference with no upstream dependency,
connect before loading upstream consumers, and let the parent admit the link before the full barrier. Control
readiness means admission is possible, not that every host object is live. Keep worker bootstrap counted as busy
until the full barrier settles, and report a later failure through the worker's fatal/startup-error path. Never await a
reference whose object needs the parent (a remote one, or a local one that holds one) before the parent admits the link.

To hand out a native object a resource opens (a local type's opaque member), expose the resource. References read its value once the object is live, and undefined before:

ts
host.serve(Harness, (_, _host, self) => {
	const durable = self.resource("durable", { version: 1, open: () => DurableHarness.open(storage), close: (h) => h.close() });
	return { harness: self.expose(durable) };  // contract member: harness: opaque<DurableHarness>("pi.durable-harness@1")
});

const harness = await ctx.get(Harness, {});
harness.harness.resume();  // the DurableHarness itself

Principals and variants

The application defines its principals and how they're keyed. Chord invents none: the app gives every principal a
request can act as.

ts
type Principal =
	| { kind: "owner"; access: "local" | "relay" }
	| { kind: "guest"; id: string; name: string; session: string; mode: "view" | "full"; revision: number }
	| { kind: "worker"; session: string; generation: number }
	| { kind: "server" }   // the parent's own requests, as a worker sees them
	| { kind: "host" };    // a host's own code

const host = createHost<Principal>({
	name: "server",
	self: { kind: "host" },
	principalKey: (p) =>
		p.kind === "owner" ? `owner:${p.access}` :
		p.kind === "guest" ? `guest:${p.id}:r${p.revision}` :
		p.kind === "worker" ? `worker:${p.session}:${p.generation}` :
		p.kind,
});
  • createHost types every factory's principal, ctx.principal, routes, accept and
    invalidatePrincipal patterns, so code narrows on kind without casts. Facet modules say it too:
    defineFacet((api) => …) (an app usually exports its own defineFacet and Ctx aliases). Without a type
    argument, principals are unknown.
  • There is one object per principal key: two tabs of the owner share an object, and a guest gets its own.
  • Code inside a host acts as self, so internal objects are shared. A principal with the same key as self is the
    host's own, whichever object carries it.
  • A link stamps the principal of whatever connects to it. Callers can never supply their own.
  • A child host names what its parent's own requests act as: connect(link, { principal }) (or
    upstream: { link, principal }). Requests the parent relays act as the caller it forwards.
  • A route names what the hosts it resolves act as when they call back: route(prefix, { …, principal: (name) => … }).

A remote factory picks a variant:

ts
host.serve(Chat, ({ session, conversation }, principal, self) => {
	if (!canView(principal, session)) throw forbidden();
	const c = self.get(Conversation, { session, conversation });  // as host: one shared instance
	if (!canSend(principal)) return { variant: "viewer", view: c.view };
	const author = authorOf(principal);
	return {
		variant: "participant",
		view: c.view,
		send: async (input, ctx) => c.submit(input, author, ctx),
		stop: async (ctx) => {
			await c.stop(author, ctx);
		},
	};
});
ts
const chat = await ctx.get(Chat, { session: "S1", conversation: 1 });  // on session:S1
if (chat.variant === "participant") showComposer(chat.send);  // narrows: `send` exists only there
else showReadOnly();

What a variant doesn't do:

  • It isn't complete authorization. Methods still check their arguments: that a submission belongs to this conversation, or a session ID to this principal.
  • It doesn't cover flags that change while you hold the object. Those are state, for example you: { canDraw: boolean }.
  • Revocation is host.invalidatePrincipal(match). It force-disposes matching objects, here and over links, and their references become gone. A new get runs the factory with the new grants.

Local types have no variants. Their only callers are inside the host, acting as its self. If a local object needs to know who's acting, pass that explicitly, like author above.

References

Where API Released
a factory self.get(Type, input): synchronous; the object goes live after it is ready with the object (or its replaced declaration)
a call await ctx.get(Type, input, { state? }) when the call settles
outside Chord await host.get(Type, input, { signal?, state? }) ref.release()
a UI runtime host.hold(Type, input): synchronous, possibly loading ref.release()
ts
const chat = await ctx.get(Chat, input);  // ready: live, every state member of its variant hydrated
chat.status;  // "loading" | "live" | "released" | "gone"
chat.variant;  // the current variant; a multi-variant reference narrows on it
chat.shape;  // { variant, members, contract }
const send = chat.send;  // a stable function: always dispatches to the current code
chat.view.value;  // the current value, typed T
chat.view.subscribe((v) => render(v));
chat.onStatus((status, error) => { if (status === "gone") reconnect(error); });
chat.release();
  • A reference you hold is usable: an awaited get resolves once the object is live and every state member of its variant has its first snapshot, local or remote. It rejects with the activation error (forbidden, unreachable, …), with aborted (a ctx.get whose ctx is aborted or whose call settles first; host.get's signal), or with unavailable/ready_timeout after the host's readyTimeout. Only that reference is released then.
  • A held reference stays subscribed to the state of its variant, with or without listeners, until it is released. After a link loss the last values stay readable while it re-holds (status loading).
  • Reading state before the first hydration throws not_ready (a hold that is still loading, a member read inside a factory). A member outside the current variant throws not_in_variant; an ended reference throws its end (released, or the gone error). A listener added before the hydration gets its snapshot then.
  • state: false gives a method-only reference: it receives no state (unless another reference to the same object wants it) and is ready once its object is live.
  • Calls on a loading reference wait until it's live, respecting ctx cancellation and a deadline.
  • A member missing from the current variant rejects with not_in_variant.
  • After release(), every member rejects with released.
  • gone means the object was forced away while referenced: revocation, archive, its host dying. Call get again, or show "unavailable". onStatus reports every status change (live, loading while a link reconnects, released, gone with the error), so a UI needn't poll.
  • References to one remote object share one binding per host and key (type, normalized input, principal key, contract fingerprint): one hold over the link, one stream per state member, calls over the same hold. Each reference keeps its own status, observers and release. With createHost({ linger: { types: { [typeName]: ms }, cap } }), a binding whose last reference is released stays held (and hydrated) for ms, at most cap (default 4) at a time, so taking the reference again costs nothing on the link.
  • Return values are values. Anything long-lived you want to share is an object you reach with get.

State

ts
const todos = self.state("todos", Type.Array(TodoSchema), []);

todos.change(ctx, (draft) => {
	draft.push({ id: "t1", text: "Write docs", done: false });
});
todos.change(ctx, (draft) => {
	draft[0].done = true;
});
todos.replace(ctx, []);

todos.value;  // immutable
todos.subscribe((value) => render(value));

// mirroring an external immutable store: subscribers get a diff, not the whole value
view.replace(ctx, conversation.view);                    // diffs when the new value reuses old containers
view.replace(ctx, rebuilt, { diff: true });              // always diff
  • A change is atomic: one revision, or nothing if the callback throws.
  • change only touches the paths you mutate, so fields your code doesn't know about survive. Use replace only where your code owns the whole value.
  • Values are immutable. Never mutate what you read or receive.
  • Remote references get a snapshot of each state member of their variant, then deltas. After a reconnect or a reload they get a reset snapshot, and nothing is replayed.
  • replace sends a structural diff when the new value shares containers with the old one (a new revision of the same state), and a single replacement otherwise. { diff: true | false } forces either. The value is the same either way.
  • An implementation may expose another object's state member as its own: return { variant: "viewer", view: conversation.view }. The object goes live only once that member has its value, local or remote.

A cell whose first value comes from a source declares { init } instead of an initial value:

ts
const db = self.resource("db", { version: 1, open: () => openSqlite(path), close: (db) => db.close() });
const notes = self.state("notes", Type.Array(NoteSchema), { init: () => db.value.all("SELECT * FROM notes") });

init(ctx) runs once the resources are open and the references live, before the object goes live, so no method,
listener, task or subscriber sees a value that did not come from the source. A task that loads the value instead
starts only after the object is live: a method called in between answers from the placeholder (a note that exists
reads as unknown), and an async load can overwrite a write made during its await. init may be async and gets a
ctx to call other objects. The inits of one object run in parallel, so one must not read another's cell. A throw or
an invalid value fails the activation. A reload keeps the cell and its value and does not run init again; a cell the
reload adds runs its init while the reload prepares, and a failure refuses the reload.

init reads a source the object has anyway. It saves nothing: what must outlive the process is written to that source
(a database, a file, a Durable document) by the code that changes it.

ts
const visible = self.derive([catalog.rows], (rows) => rows.filter((r) => visibleTo(principal, r)));
const openCount = self.derive([todos], (t) => t.filter((x) => !x.done).length);

Derivation functions are synchronous. A derivation has no name: every reload makes a new one, computed when the
reload commits.

Calls, ctx and roots

Every method gets (args, ctx), or (ctx) when it takes no arguments. Pass ctx on to everything you call:

ts
send: async (input, ctx) => {
	ctx.signal.throwIfAborted();
	const uploads = await ctx.get(Uploads, {});  // a reference owned by this call; released when it settles
	const files = await uploads.resolve({ ids: input.uploads }, ctx);
	return c.submit({ ...input, files }, author, ctx);
},

A root is a short tree of calls, started at ingress:

  • by a link, for a request that arrives without one;
  • by host.root(fn), for timers and scripts;
  • by each event delivered to a listener (self.on);
  • by each Chord call made from a task or a Durable phase.

A root runs on the code that was current when it started (its code version), across hosts, until it ends. Keep roots short. Long-running work is a task, and long waits are state.

ts
// wrong: holds a root (and its version of the code) open for minutes
approve: async (ctx) => waitForHuman(ctx),

// right: record the request as state; the answer is a separate call
request: async (req, ctx) => {
	pending.change(ctx, (d) => {
		d.push(req);
	});
},
answer: async ({ id, value }, ctx) => {
	pending.change(ctx, (d) => {
		settle(d, id, value);
	});
},

Lifetime

An object lives while it's referenced, and every reference has an owner:

Owner Acquired with Released when
another object self.get that object is disposed, or no longer declares it
a call ctx.get the call settles
a connection a remote host's get release, disconnect, revocation
the host lifetime: "host" host shutdown (or a reload removing its facet)
UI useObject unmount
  • Disposal runs after the last reference is released, plus linger. Tasks are aborted, listeners removed, resources closed in reverse order (every close runs, and errors are collected), then the object's own references are released, which can dispose further objects.
  • A resource's close may still use the objects its object references. That holds for host shutdown and revocation too: host.close() and invalidatePrincipal dispose referrers before what they reference, and close links last. Bound remote calls in close with a signal: a call over a link that is already down waits for a reconnect.
  • Forced disposal (revocation, archive) makes references gone at once and aborts in-flight calls.
  • References must not form cycles. Inside one host this is checked at get.
ts
host.serve(Conversation, conversationFactory, { linger: 30_000 });  // switching back quickly doesn't reopen its watch
host.serve(Index, indexFactory, { lifetime: "host" });              // one object from load to host shutdown

lifetime: "host" is for local types with an empty input (or only defaults): the type has exactly one object,
created for the host principal when its facet loads. Every get reaches that object, whoever asks; a get with
another input is invalid. References others hold do not change its lifetime, and it cannot linger. Only such
objects may register into registries. TypeScript refuses lifetime: "host" for a remote type, for an input with a
required field and together with linger; serve checks the same at runtime, for callers without types.

Registries

ts
export const Routes = defineRegistry<RouteHandler>("http.routes", { conflict: "error" });

// contribute (only from `lifetime: "host"` objects)
host.serve(Downloads, (_, _host, self) => {
	const blobs = self.get(Blobs, {});
	self.register(Routes, {
		"GET /download": async (request, who, ctx) => {
			const file = await blobs.find({ id: request.query.id }, ctx);
			if (!canView(who, file.session)) return forbidden();
			return stream(file, ctx);
		},
	});
	return {};
}, { lifetime: "host" });

// consume: a request sees the table of its own code version
server.on("request", (req, res) =>
	host.root(async (ctx) => {
		const route = host.registry(Routes).get(`${req.method} ${pathOf(req)}`, ctx);
		return route ? route(req, principalOf(req), ctx) : notFound(res);
	}),
);
  • Each code version has one complete table. A reload never leaves a moment where an entry is missing.
  • A request that started before a reload keeps using the old table.
  • Order is first-registration order and survives replacement.
  • conflict: "error" makes a duplicate key fail the registration (or the reload).

Plugins

A plugin is a feature with one facet per host kind:

text
plugins/chat/
  contract.ts    Chat, Conversation, Drafts
  server.ts      facet for the server
  worker.ts      facet for session workers
  web.tsx        facet for browsers
ts
// plugins/chat/worker.ts
import { defineFacet } from "@earendil-works/chord";
import { Chat, Conversation } from "./contract.ts";

export default defineFacet((host) => {
	host.serve(Conversation, conversationFactory, { linger: 30_000 });
	host.serve(Chat, chatFactory);
});
ts
// a host loads the plugins' facets for its kind
await host.load([
	new URL("../plugins/chat/worker.ts", import.meta.url),
	new URL("../plugins/status/worker.ts", import.meta.url),
]);

Built-in plugins are trusted code from this repository and load directly from source.

The facet's setup argument is its host, scoped to what a plugin needs: serve, name, root(fn), registry(R) and signal, which aborts once this run of the facet is no longer installed (a reload replaced or removed it, or the reload that loaded it aborted). Code that is not a Chord method, listener or task (Durable tools and hooks, callbacks of native libraries) keeps it to start roots and read registries, instead of reaching for a process-global host:

ts
export default defineFacet((host) => {
	host.serve(Sessions, sessionsFactory);
	tools.register("rename_session", (args) =>
		host.root(async (ctx) => (await ctx.get(Sessions, {})).rename(args, ctx)),
	);
});

Hot reload

ts
await host.reload([new URL("../plugins/chat/worker.ts", import.meta.url)]);  // or reload every changed plugin

What happens

  1. Load the new modules.
  2. Prepare: run the new factories over the existing objects, without side effects. Cell writes throw, listeners and tasks don't start, registrations are collected, and changed resources are opened next to the old ones. Contracts are checked against every live consumer. Any failure leaves everything as it was.
  3. Gate: only if a cell's version was raised or an exclusive resource changes.
  4. Commit: in one synchronous step, install the new code, swap registry tables, restart tasks, rebind listeners, and send reset and shape updates to subscribers.
  5. Retire: old code keeps running the roots it started. Once they end, it's dropped, along with whatever it alone declared. If they are still running when the next reload starts, that reload waits up to retireDeadline, then aborts them (their ctx.signal) before it closes what only the old code used.

Compatible changes: no waiting

Bug fixes, new methods, new cells, new resources and registration changes all switch immediately. A root that started on the old code finishes on it, and new roots get the new code.

Changing what state looks like or means: bump the cell's version

ts
// v1: { running, tasks } where tasks counted phases
// v2: tasks counts tasks, and usage is new
host.serve(StatusStore, ({ session }, _host, self) => {
	const status = self.state("status", StatusV2, { running: false, tasks: 0, usage: emptyUsage() }, {
		version: 2,
		migrate: (old, from) => (from < 2 ? { running: old.running, tasks: 0, usage: emptyUsage() } : old),
	});
	return {
		status,
		report: async (s, ctx) => {
			status.replace(ctx, s);
		},
	};
});

At the gate, for each live StatusStore:

  1. Roots already inside finish. Their re-entrant calls are let in.
  2. Old roots that haven't touched it yet get upgrade_conflict instead of old code over migrated state. They retry as a new root.
  3. migrate runs on a private copy, and the result is validated against the new schema.
  4. Commit. New roots use the new code and the migrated state.

If the roots inside don't finish within the deadline (5 s), the reload is aborted and nothing changes. The error names the blocking roots.

The version belongs to the cell (default 1); other cells of the object are not migrated. The runtime compares each
cell's schema and version between old and new code:

  • unchanged: compatible;
  • version raised: migrate (without migrate, the value is kept and must fit the new schema);
  • schema changed without a raised version, or a lowered version: the reload is refused.

A change of meaning with an identical schema needs a bump too. Nothing can detect that one for you.

Resources

ts
const db = self.resource("db", { version: 2, open: () => openSqlite(path, { wal: true }), close: (db) => db.close() });  // 1 → 2: reopen
const port = self.resource("port", { version: 1, exclusive: true, open: () => listen(8787), close: (s) => s.close() });
  • A reload keeps a resource's open value unless its version, deps or exclusive change: open doesn't run
    again. The version is required: a native value has no schema to detect a change. The kept resource still takes the
    new code's declaration at the commit, so a later close is the new one.
  • A changed resource opens next to the old one during prepare. The old one is closed with its own close once the
    old code retires.
  • An exclusive resource can't exist twice, so it's swapped at the gate: close the old one, open the new one, and reopen the old one if that fails.

Contract changes

Change What happens
Additive, same version reloads; consumers keep working
Breaking, version bumped, with accepts[old] reloads; old consumers go through the adapter
Breaking, without an adapter refused, unless the consumers reload in the same batch
Breaking, version not bumped refused: "consumer needs member …"

The host's own reference to a lifetime: "host" object is a lifecycle pin, not an old-version consumer. When a reload replaces
its provider, that reference adopts the new contract at commit; it does not require an accepts adapter for itself.
The object, cells and unchanged resources stay alive without reactivation. A refused reload keeps the original
reference and contract. Other references (self.get, ctx.get, host.get, peers) still obey the compatibility rules above;
state-version bumps and migrations are still required when stored state changes.

Several plugins at once

Reload them together: host.reload([a, b]). Prepare sees all new code at once, so a changed contract and its updated consumer check against each other.

On the server, host.reload(serverFacets, { remote: { "session:": workerFacets } }) reloads every connected worker too. Its report covers the server; report.children["session:S1"] says what each worker changed, prepared and gated.

ts
// server
const server = createHost<Principal>({ name: "server", self: { kind: "host" }, principalKey });
server.accept(webSocketLink(wss, { principal: (req) => authenticate(req) }));  // stamps owner or guest from cookies
server.route("session:", {
	authorize: (principal, host) => canReach(principal, host),  // before resolving or spawning
	resolve: (host) => workers.ensure(host),  // a link to the worker, spawned if needed
	principal: (host) => workers.principal(host),  // what the worker acts as when it calls the server
});

// worker
const worker = createHost<Principal>({
	name: `session:${id}`,
	self: { kind: "host" },
	principalKey,
	upstream: { link: ipcLink(process), principal: { kind: "server" } },  // the server's own requests act as this
});

// anywhere
const chat = await ctx.get(Chat, { session: "S1", conversation: 1 });  // browser → server → worker session:S1
const sessions = self.get(Sessions, {});  // `hostNamed("server")`: worker → server, acting as worker{S1}
  • The server relays between its children: worker to worker, and browser to worker.

  • Disconnects: when a browser disconnects, exactly the references it had in workers are released.

  • Principals across links:

    • a forwarded principal is accepted only from the parent link;
    • calls an object makes itself act as its host;
    • links ping, and a dead link releases everything held through it.
  • Client origins: a hold that comes from a browser carries an opaque id of its connection (origin), forwarded by the server when it relays the hold, so a worker can tell its clients apart. A client cannot set it. A disconnect releases that client's holds at once.

  • Slow peers: a peer acknowledges what it processed. While a window (window, default 256 KiB) of frames is unacknowledged, state waits and is coalesced per member's stream; calls, results and the first snapshots of a new reference are never held back, so a get does not wait behind other references' state. Waiting ops are capped per link (pendingCap, default four windows): beyond, the oldest streams are replaced by their current value. So under load a result can arrive before the state change it caused: wait for the state you need instead of assuming it is there when the call returns.

  • Reloads across hosts: the server coordinates prepare and commit for every host.

  • Workers that start behind (spawned after a reload) are caught up to the server's code version before anything is routed to them. Calls their roots make to the server meanwhile run in the server's current code version instead of failing superseded.

  • Protocol version: both ends announce the link protocol version in their hello. A peer of another version is not
    talked to: the link closes, and onProtocolMismatch (a LinkOptions field of connect and accept) tells the
    side that has to load new code. A browser reloads its page instead of reconnecting:

    ts
    host.connect(webSocketLink(socket), { onProtocolMismatch: () => location.reload() });
  • Browsers don't take part in commits. They learn about new code through shape and reset messages, and an incompatible contract makes their calls fail with upgrade_required.

Durable

A worker's host provides the Durable Harness as a local object of lifetime "host". A Durable phase isn't a root: it's pinned to its registry snapshot by Durable itself. Each Chord call a hook or tool makes starts a fresh short root.

ts
host.serve(Harness, (_, _host, self) => {
	const durable = self.resource("durable", {
		version: 1,
		open: () => DurableHarness.open(storage, { models, registry: durableRegistry(host, Extensions) }),  // a reader over the code version tables
		close: (h) => h.close(),
	});
	return { conversation: (id: number) => durable.value.conversation(id) };
}, { lifetime: "host" });

// a plugin contributes a Durable extension like any registration
host.serve(Guard, (_, _host, self) => {
	self.register(Extensions, {
		guard: defineExtension({
			name: "guard",
			hooks: [hook(ToolTask, { beforeTool: (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined) })],
		}),
	});
	return {};
}, { lifetime: "host" });

A reload changing the guard: Durable phases already running keep the old snapshot, the next phase uses the new one, and there's never a phase without a guard.

Errors

Code Meaning
forbidden The factory or a method refused this principal
invalid Input, arguments or a result failed validation
not_ready A declared resource or reference was used inside a factory, or state read before its reference's first hydration
not_in_variant The member doesn't exist in the variant you have
released The reference was released
gone The object was forced away while referenced. Call get again.
contract_mismatch The provider's contract doesn't cover the consumer's, and no adapter exists
upgrade_conflict An old root reached an object migrated meanwhile. Retry as a new root.
superseded The root's code version is no longer available on this host. Retry.
upgrade_required A browser's contract is outdated. Reload its web facets.
unavailable The target host can't be reached or started; reason ready_timeout: a reference was not ready within readyTimeout
aborted The call's ctx was cancelled

Rules of thumb

Do Not
Keep reloadable state in cells Closure variables (lost on reload)
Bump the cell's version, with migrate, when a cell's shape or meaning changes Changing a schema without a bump (refused) or a meaning without a bump (silent)
Bump the contract version on breaking changes; add accepts for old consumers Renaming or removing members at the same version (refused)
Do slow setup in a resource's open Awaiting inside the factory (it's synchronous)
Do side effects in resources, listeners, tasks and registrations Effects in the factory body (they run during prepare)
Use ctx.get inside methods self.get inside methods (a new reference per call)
Keep roots short; long waits are state Awaiting a human inside a method
Check arguments in methods Treating the variant as all of authorization
Pass ctx everywhere Starting background work with the caller's ctx (use a task)
Treat state values as immutable Mutating what you read

Packages Chord Docs Spec (`packages/chord/docs/spec.md`)

Chord specification

This document specifies the runtime in src. Where it and the code disagree, the disagreement is a bug. Known limitations and decisions lists what the runtime does not do yet and the choices the sections below leave open.

Hard constraints:

  • Chord's runtime depends only on its own delta module (tracker, applyImmutable, encoder/decoder, diff) and Node/browser built-ins plus typebox for schemas. No other Pi packages.
  • Built-in plugins are trusted, loaded from source in-process. The plugin loader uses dynamic import() with a cache-busting query (owner-approved exception, TODO(loader): replace with an isolating, evicting loader; old module copies leak; TUI plugins need isolation).
  • Hot reload is required. Host restart only for Chord/runtime code changes or host-level crash recovery.

1. Concepts

Term Meaning
facet A plugin's code for one host (surface): it serves object types (defineFacet).
object type Name + contract. remote (addressable from other hosts; schemas for input, variant state members, method args/results) or local (host-only; members may be anything, including functions and native objects, but must be listed in the contract: method, opaque or a state schema).
factory (input, principal, self) => implementation. Synchronous. Registered with host.serve(type, factory, options) from a plugin facet.
object One instance of an object type. Stable identity per (type, canonicalInput, principalKey). Owns cells, resources, references, listeners, tasks, registrations. Survives reloads.
implementation The object's code: what a factory run returns (methods + state members; variant for remote types). Immutable, per code version.
reference What get returns: a late-bound claim on an object, never the object or its implementation. An object lives while referenced.
principal App-defined. Stamped by links. In-host code acts as the host principal.
root One short Chord call tree, admitted at trusted ingress (link request without root, UI event, timer, host.root). Carries { rootId, codeVersion }.
code version One committed set of implementations/registrations per host (server-issued id).
code holder Long-lived execution that is NOT a root: Durable phases/tool runs, browsers, self.task loops. Every Chord call they make starts a fresh short root.

2. Contracts

Contracts are runtime values for every type (shipped bundles have no TypeScript; third-party plugins are built separately). TS types are inferred from them.

ts
export const Chat = remote("pi.chat", {
	version: 1,                                   // contract version: a plain number, bumped only on breaking changes
	input: Type.Object({ session: Type.String(), conversation: Type.Number() }),
	host: hostFamily("session:", "session"),       // where its objects live: `session:` + input.session
	variants: {
		viewer: { state: { view: ViewSchema } },
		participant: {
			state: { view: ViewSchema },
			methods: { send: method(SendSchema, Type.Object({ submissionId: Type.Number() })) },
		},
	},
});

export const Index = local("pi.index", {
	version: 1,
	input: Type.Object({ session: Type.String() }),
	members: {
		search: method(Type.Object({ query: Type.String() }), Type.Array(Hit)),
		onChange: method(Type.Function([Change], Type.Void()), Unsubscribe),   // non-JSON allowed for locals
		harness: opaque("pi.durable-harness@1"),                               // native object, by name+version
	},
});
  • Input identity: normalize (apply defaults, strip unknown), validate, then canonical JSON with fields equal to their schema default omitted. Changing defaults or normalization requires a new type name.
  • Remote calls validate args/results and state values at the provider boundary. Local calls are not validated per call.
  • Local contracts list every member (members: { search: method(...), harness: opaque(...), status: Schema }). A reference that is still loading must know whether h.x is a method (a caller that waits until live), a state member (a readable) or an opaque value, before any implementation exists. Members missing from the contract are found only once the reference is live.
  • Every bundle embeds the contract tokens it was built against (its own copy). Fingerprint = hash of the canonical description.
  • Placement (remote types, required): host: hostNamed(name) (one host) or hostFamily(prefix, key) (the host prefix + the input's required string field key). Data only, in the description (and so the fingerprint and the hold token); there is no at option. A get normalizes the input with the caller's contract, resolves the host and acquires locally if that is this host, else remotely. serve (and a reload that loads a facet) refuses a type whose placement excludes the host's name. A host refuses a hold whose input its own contract places elsewhere (invalid/placement), and a relay refuses a hold whose target disagrees with its token's placement and input. Coverage requires equal placements, and an accepts adapter never bridges two placements: a moved type is a breaking change. A remote reference's address is the type, the normalized input and the principal: no host name.

Compatibility rule

A consumer built against version M binds to a provider at version N iff N = M and the provider's description covers the consumer's; otherwise only through accepts[M] adapters declared by the provider (serve(type, factory, { accepts: { 1: (current) => v1View } })); else contract_mismatch.

Covers: every consumer member exists with the same kind; methods: both take arguments or neither does, the provider accepts every argument the consumer can send (new fields optional, nothing newly required or retyped), and the provider's result fits the consumer's expected result (extra fields fine; a void consumer accepts any); state: provider value fits consumer schema (extra fields fine); opaque: same name@version.

Additive changes need no bump. Breaking changes (remove/rename/retype, newly required, or a change of meaning with the same shape) bump the number. Same version + non-covering description = forgotten bump, refused. Meaning changes without a bump are undetectable (author's responsibility, as with Durable document versions).

Checked at: get binding, reload prepare (new provider token vs every surviving consumer holding the type and every loaded plugin importing it), remote references (consumer token in the hold frame).

The exact host-owned reference to a lifetime: "host" object is a lifecycle pin, not a surviving old-contract consumer. When its
provider is replaced, commit replaces that reference with one bound to the new provider contract, acquiring it before
releasing the old one so the same object, state and unchanged resources survive. It needs no accepts adapter
for its own previous contract. Prepare/abort do not change that reference. All other consumers retain their own tokens
and the normal coverage/adapter checks; state-version and migration checks are unchanged.

3. Factory and declarations

Factories are synchronous; returning a promise throws. A declaration has a name only if something of it survives a reload and must be found again: state (its value) and resources (the open value), plus listeners (a kept subscription). Names are strings, unique per object per declaration kind (a repeated name is invalid/duplicate); kinds have separate names. Derivations and tasks have no names: a reload makes new ones. References are identified by their address.

ts
host.serve(StatusStore, ({ session }, _host, self) => {
	const status = self.state("status", StatusSchema, { running: false, tasks: 0 },  // schema required, initial value
		{ version: 2, migrate: (old, from) => upgrade(old, from) });                     // per cell; default version 1
	const errors = self.state("errors", Type.Array(Type.String()), []);
	const catalog = self.get(Catalog, {});                         // reference; identity = canonical address (+ at)
	const watch = self.resource("watch", {
		version: 1, deps: [catalog], exclusive: false,
		open: async ({ signal }) => openWatch(session, signal),
		close: async (w) => w.close(),
	});
	self.on("changes", watch, (w) => w.events, (event, ctx) => status.change(ctx, (d) => { d.tasks = event.tasks; }));
	self.task(async (ctx) => { /* loop until ctx.signal aborts */ });
	const visible = self.derive([status], (s) => project(s));      // synchronous
	self.register(Routes, { "GET /status": handler });              // registry keys are the registry's own keys; `lifetime: "host"` objects only
	return { status: visible, errors, report: (args, ctx) => { status.replace(ctx, args); } };  // exposed state = what is returned
}, {
	linger: 30_000,                                                  // with lifetime "referenced" (the default) only
});
Declaration Survives reload Notes
state(name, schema, initial | { init }, { version?, migrate? }) same cell and value (initial ignored, init not run); migrated when its version rises; a cell the reload adds runs init at prepare (gated objects: at the gate) Schema required (no TypeScript at runtime). change(ctx, draft => void), replace(ctx, value), value, subscribe. Writes through change touch only mutated paths. Private state is a cell the implementation does not return. init(ctx) (may be async) gives the first value, see activation
get(type, input, { at? }) kept if declared again (same address) returns a reference
resource(name, { version, deps?, exclusive?, open, close? }) kept unless version, deps or exclusive change; a kept resource adopts the new declaration (its close) at commit open runs at prepare (non-exclusive) or at the gate (exclusive); returns a resource whose .value is the open value; a replaced resource closes with its own declaration's close
on(name, source, [select,] handler) same subscription while source and select (identity or source text) are unchanged; handler swapped each event starts a fresh root in the current code version; select is not re-run for a kept subscription. State sources deliver their current value at start and after every reload that replaced them
task(fn) restarted with new code at commit: the old run is aborted via ctx.signal, and the new one starts once it finished (at most taskStopDeadline, default 5 s, then anyway, reported) a code holder; calls inside start fresh roots
derive(sources, fn) a new derivation, computed at commit synchronous fn
register(registry, { key: value }) value swapped at commit lifetime: "host" objects only
expose(resource) follows the resource a local member (usually opaque) whose reference value is the resource's value once live

State members of an implementation may be cells, derivations, or state members of references the object holds (they are defined once the object is live). cell.replace(ctx, value, { diff? }) publishes a structural diff when the new value shares containers with the old one (or diff: true), else r. Facet setup receives { serve, name, root, registry, signal } of its host; signal aborts once this run of the facet is no longer installed (a committed reload replaced or removed it, the reload that loaded it aborted, or the host closed).

migrate per cell is (old, fromVersion) => new. A cell may be several versions behind (author bumped more than once between reloads). Without migrate, a raised version keeps the value, which must fit the new schema.

Listeners on state reconcile. A listener whose source is state (a cell, a derivation, a reference's state member) is subscribed when it starts and gets the current value, then every new value. A reload that replaces its source (every derivation is new; a reference to another address is another source) subscribes it again, so it gets the current value again: its handler must reconcile (be idempotent), not count events. An event source (a resource's events, any other subscribe) delivers no current value; a kept subscription to it is not touched by a reload.

Methods. method(args?, result?): without args the method takes none (callers ref.name(ctx?), implementation (ctx) => …; method(undefined, result) for one that returns something); without result it is void (callers get undefined, whatever the implementation returned). The description records both (absent fields), and the call frame carries no args and the result frame no value for them. A consumer and provider must agree on whether a method takes arguments; a consumer without a result accepts a provider's.

Removed vs guide: self.own, holdWhile, async factories, inferred dependencies.

Synchronous factory, asynchronous activation

self.get and self.resource only declare and return placeholders. Activation of an object:

  1. run the factory synchronously (declarations);
  2. await declared resources' open() (e.g. connect a DB);
  3. await declared references becoming live (dependencies activate first, coalesced);
  4. run the init of every cell declared with one, in parallel, each in a root of its own acting as the host principal; validate and install the values;
  5. attach derivations, start listeners and tasks;
  6. object is live: references live, ready resolves.

Inside the factory, placeholders must not be used (resource.value and an init cell's value throw not_ready; reference calls throw). An init that throws, or returns a value its schema refuses, fails the activation like a failed open. An init must not wait for its own object or one that holds it (it is not live yet), and must not read another init cell of its object (they run in parallel). init gives a first value from a source the object reads anyway; it does not persist anything, and a cell is not saved anywhere by having one. Methods/listeners/tasks run only on live objects, so their dependencies are live. An object goes live only once its references are ready (live and, for remote ones, hydrated) and the state sources it exposes or derives from have their values, also where they forward a remote reference held some other way; waiting for references is bounded by the host's readyTimeout (default 60 s, unavailable/ready_timeout, also for a reference cycle across hosts). External consumers holding a loading reference (host.hold): calls wait until live (ctx cancellation, deadline); state members throw not_ready until the first hydration. Activation failures propagate to dependents and all waiting gets; the next get retries.

Lifetime

serve(type, factory, { lifetime }):

lifetime Created Ends linger
"referenced" (default) at the first get of its address after the last reference is released, plus linger allowed (default 0)
"host" when its facet loads (or at serve); loading waits until it is live when the host closes, or a reload removes its facet refused
  • "host" is refused at serve for remote types, for an input that does not accept {}, and together with linger;
    the types (ServeOptionsFor) refuse the same at compile time, where such an input is one with a required field.
    The type has exactly one object: the empty input (defaults applied), created for the host principal. Every get
    reaches it, whatever its principal (the factory sees the host principal); a get with another input is
    invalid/input. Other references do not change its lifetime: the host keeps its own until it closes.
  • Only "host" objects may register (invalid/not_host_lifetime otherwise).
  • A "host" object whose activation fails is started again by the next reload that changes its type. A reload that
    makes a type "host" creates its object; one that makes it "referenced" releases the host's reference, so the
    object lives on only while others reference it.

Host objects readiness

  • host.install(facet, { awaitHostObjects? }) installs declarations synchronously and returns a promise for this
    facet's host object activations. host.load(facets, { awaitHostObjects? }) installs the entire batch before awaiting
    its host object activations. awaitHostObjects defaults to true. Unreferenced objects are not activated.
  • Factory, resource-open, dependency and activation-finalization failures reject the returned promise with the
    activation error (ChordError codes preserved; ordinary errors wrapped as unavailable). Host object failures also
    reach onError. Installation is not a rollback transaction; setup/registration errors from install throw
    synchronously. Abandoned startup should close the host.
  • { awaitHostObjects: false } starts the same host objects but resolves after installation, without waiting for
    their activation. host.ready() awaits a snapshot of the host's references to its host objects at call time
    (including direct serve and deferred loads), rejects their activation failures, and does not acquire additional
    references. It is not an ongoing health check and does not await task completion or remote state hydration.
  • Local bootstrap readiness and upstream admission are separate. A worker connects its upstream link, loads with
    { awaitHostObjects: false }, awaits its local bootstrap reference (e.g. Harness, with no upstream dependency), then
    announces control readiness so its parent can accept the link. Only after admission does it await host.ready().
    Otherwise a host object reporting to the server's SessionStatus deadlocks with the parent waiting for control
    readiness. Deferred loads skip the whole barrier, not selected dependencies; callers explicitly choose local
    bootstrap references, keep bootstrap busy until full readiness, and propagate later startup errors.

4. Principals, variants, references

  • Principals are the app's type P (createHost, Host, defineFacet, Factory<C, P>, Ctx; default unknown). Chord invents none: createHost({ self }) is the principal of the host's own code (roots, code inside objects, lifetime: "host" objects), connect(link, { principal }) what the parent's own requests act as, accept(link, { principal }) what a link acts as, Route.principal(hostName) what a routed host acts as when it calls back. All are required.
  • createHost({ principalKey }) maps principal to a key that includes connection provenance and grant revision. Objects are per principal key. The host's own principal is recognized by key (principalKey(p) === principalKey(self)), not by object identity, so an equal principal never creates a second object.
  • Remote factories return { variant, ...members }; variant must be a declared variant; members must match it. Local factories return any object. variant is reserved as a member name.
  • Reference: status (loading | live | released | gone), ready promise, variant, shape ({ variant, members, contract }), onStatus(listener) (every status change, with the error for gone), late-bound members, release(). self.get is synchronous (the object goes live after its references are ready); ctx.get and host.get resolve once the reference is ready: live, and every state member of its variant hydrated (state: false: live only). They reject with the activation error, aborted (ctx aborted or settled, signal), or unavailable/ready_timeout, releasing only that reference. host.hold is the synchronous form for UI runtimes. A held remote reference stays subscribed to its variant's state until released; after a link loss its last values stay readable while it re-holds. State members are typed T; reading before the first hydration throws not_ready, outside the variant not_in_variant, after the end the end's error. A remote reference type is a union discriminated by variant. Shape changes (reload, variant) are pushed; members missing from the current variant reject not_in_variant; released references reject released; forced disposal makes references gone.
  • Shared bindings: remote references on a host with the same key (type, normalized input identity, principal key, consumer contract fingerprint) are views onto one binding: one hold, one stream per state member, calls over that hold. Views have their own owner (object, call, external), status, observers and release; the binding ends (gone) or re-holds for all of them. Without views a binding closes, or lingers for linger.types[typeName] ms (createHost({ linger: { types, cap } }), at most cap, default 4, least recently released first) while staying held and hydrated.
  • Methods are (args, ctx) => Promise<result>. Factories still check arguments; variants are not complete authorization.
  • Revocation: host.invalidatePrincipal(match) force-disposes matching objects on this host and propagates over links; links re-stamp or close.

5. Lifetime

  • Reference owners: object (declared get), call (ctx.get, released at settle), connection (remote references; relay maps per origin connection), host (lifetime: "host"), UI.
  • Activation coalesced per address; if all requesters leave before it completes, the result is disposed. Factory throw: unwind declarations, reject gets, next get retries.
  • Disposal after the last reference is released + linger: tasks aborted, listeners removed, resources closed (reverse order; all run; errors aggregated), the object's references released (cascade).
  • Bulk forced disposal (host.close, invalidatePrincipal) goes in waves, referrers before what they reference, so a resource's close can still use its object's references; host.close closes links after the objects.
  • Forced disposal: references gone immediately, new calls rejected, in-flight call contexts aborted, then cooperative cleanup.
  • Reference graph acyclic in-host (checked at get); across hosts by convention.
  • Links have ping/timeout; a dead link releases everything held through it.

6. Roots and code versions

  • Ctx = { signal, rootId, codeVersion, get(...): Promise<Ref>, principal? }. Explicit; no AsyncLocalStorage dependency (may be used in dev to warn).
  • A link creates a root for a request that carries none; nested remote calls carry {rootId, codeVersion}.
  • Dispatch: a call with code version E runs the object's implementation for E. A host retains at most current and previous code versions (two-version rule). A call for a code version the host doesn't have (older than previous, or host started later) is rejected superseded (retryable by starting a new root). Never substitute another code version.
  • Browsers never carry code versions into the server: browser calls start roots at the server. Browsers learn of changes via shape/reset pushes and a server Manifest object; incompatible contract fingerprint -> upgrade_required.

7. Reload

Explicit trigger (reload_plugins tool, button, API). Serialized; no nesting.

  1. Build/type-check gate (application tooling, outside Chord): dependency-closed set; refuse breaking remote contract changes without a contract version bump.
  2. Load: on every affected host, import new facet modules (cache-busted).
  3. Prepare: for each live object of each affected type, run the new factory in recording mode: existing cells readable, writes throw; listeners/tasks/registrations recorded, not started; references recorded; non-exclusive resources whose name is new or whose version/deps changed are opened as candidate-owned (closed on abort). Validate keys, variants, registry collisions. New objects activated during prepare use the current code version (old code). Any failure: abort, discard candidates. Vote.
  4. Gate (only objects with a cell whose version was raised or which replace an exclusive resource, plus objects depending on those resources):
    • Admission cut: from now, an old-code-version root that has not touched the object gets upgrade_conflict (retryable), not queued. Roots that already hold an activation lease on it are grandfathered; their re-entry is admitted.
    • Wait for grandfathered activations to finish, deadline (default 5 s). Missed deadline: abort the reload, reopen, nothing changed (report blocking roots).
    • Exclusive resources: close old, open new; on failure reopen old and abort.
    • Run migrate into private copies; validate.
  5. Commit (server commits itself, then sends commit to children; the decision is not persisted, see section 12; FIFO links guarantee no host receives a new-code-version root before committing): each host in one synchronous step installs migrated cell values and cell versions, installs implementations as code version E', swaps registry tables, restarts tasks with new code, rebinds listeners, pushes shape/reset to subscribers, opens gates. E' current, E previous.
    The coordinator's result carries children: each participating child's prepared report (its vote).
  6. Retire: when no roots of previous remain (and, on a child, the parent retired it), drop its implementations and release declarations only it declared. The next reload first waits for that, at most retireDeadline; then it aborts the roots still running on previous (their ctx signals), gives them up to 1 s to stop, and retires anyway. A coordinator retires before it sends prepare, so its retire frames reach the children first.

Compatible changes (no state bump, no exclusive resource change) skip the gate: no waiting.

State compatibility rule: reload prepare compares each cell's old and new schema and version. Unchanged: compatible (no gate). Version raised: gate + migrate (or the kept value), validated against the new schema. Schema changed without a raised version: reload refused. A lowered version: refused. A change of meaning with an identical schema needs a bump too (undetectable; author's responsibility). Initial values and migrate outputs are validated; per-write validation in dev mode.

8. Registries

ts
export const Routes = defineRegistry<RouteHandler>("http.routes", { conflict: "error" });
self.register(Routes, { "GET /download": handler });       // in a `lifetime: "host"` object; keys are the registry's keys
host.registry(Routes).get("GET /download", ctx);            // table of ctx.codeVersion (or current without ctx)
host.registry(Routes).subscribe((table, codeVersion) => ...);     // bridges for imperative consumers (Durable)

Per-code-version immutable tables; order = first registration order, kept on replace. An application can implement Durable's RegistryReader from the table (snapshot per code version) so phases pin a complete snapshot; Chord does not ship that bridge.

  • createHost({ name, self, principalKey, upstream?: { link, principal } }); host.accept(link, { principal }) for children; host.connect(link, { principal }) for the parent; host.route(prefix, { authorize(principal, hostName), resolve(hostName) => Promise<Link>, principal(hostName) }).
  • Links: memoryLinkPair() (tests), Node IPC (child_process), WebSocket (server ws, browser WebSocket).
  • Principal stamping: the accepting side stamps the principal of what connects (from its authenticator); forwarded principals accepted only from the parent link; the parent's own requests (no forwarded principal) act as the principal given to connect; payload principal fields ignored.
  • A child that connects behind its parent's code version is fenced (nothing routed to it) and reconciled by a reload of its modules to the parent's code version. Calls its roots of the old code version make upstream are admitted in the parent's current code version (the parent never ran that code version), not rejected superseded.
  • Relay: server forwards a hold to the child link of its target host (at, resolved by the consumer from the contract's placement and the normalized input, and checked against both), mapping references per origin connection; authorizes before resolving/spawning and re-checks after.
  • Wire frames (JSON): hello/bye, hold/release (a reference over a link: hold returns shape or gone; there is no separate get), shape, call/result/cancel, sub/unsub/resync and state, gone, root_end, retire, invalidate, ping/pong, ack, reload control (prepare/vote/commit/abort).
  • State: a hold implies the state of its variant (state: false: none until sub; unsub stops it). The provider answers with units: a shape whose state lists the members the consumer lacks, followed by one state frame as: "snap" per listed member; the consumer applies the shape once those snapshots are here. Later state frames of a member (addressed by hold id and member m) are ops (per-stream delta encoder), reset, or error; a snap/reset without value carries undefined. A failed snapshot before the first hydration ends the reference with its error. A reload or variant change sends a unit with the members that are new; members that left stop. again marks a re-hold after a link loss (the consumer has values). origin names the client connection a hold comes from (stamped by the host that accepted the client, forwarded by relays, honored only from the parent link).
  • Protocol version: hello carries protocol (PROTOCOL_VERSION, now 4; absent means 1). A side that sees another version processes nothing more, calls LinkOptions.onProtocolMismatch({ local, peer }) and closes the link with reason protocol-version ; the other side's connection reads that reason and reports the mismatch too. Peers do not negotiate: the outdated side must load the current code (a browser reloads its page). Frame fields follow the version: the shape frame names the variant variant, code versions travel as codeVersion (2); remote contract tokens carry their placement (host), and a hold carries the input normalized by the consumer (3); a hold implies its state, units, origin, and shape without a code version (4).
  • Flow control: both ends announce acks in their hello and acknowledge what they processed (ack, a cumulative count of every frame but ack). While the frames not yet acknowledged exceed the window (window, default 256 KiB of JSON text), state waits at the sender in slots flushed in creation order: one per member stream (ops are appended, a snapshot or reset replaces it, a release or unsub drops it) and one per hold for a unit (lazy: built from the current values when sent; a newer unit merges into it). Waiting ops are capped per link (pendingCap, default four windows): beyond, the oldest streams become a reset of their current value built at flush (a relay sends resync downstream and drops that member's ops until the reset arrives). The first unit of a hold that is not again and every frame but state are sent at once. Links that report no frame sizes (IPC) are never windowed; a peer without acks is neither windowed nor acknowledged. An acceptor can limit the calls a peer has in progress (maxCalls); calls beyond it are answered at once with unavailable/too_many_calls. Nothing outside the connection may add frames to a link: acknowledgements count them.

10. Host manager (library)

createHostManager({ driver, keepAlive }): fork driver (entry, init, lock, env), serialized start/stop/replace, exit-before-replace, process-tree kill after grace, control channel (ready/fatal/shutdown), liveness callback (booting, activeCalls, viewers with heartbeats, durable tasks, idleFor).

11. Test coverage

The tests cover at least: lifetime/references/linger/forced disposal/gone; activation coalescing and abandonment; principals and variants; remote calls over memory links incl. relay and principal forgery attempts; state snap/ops/reset over a link with reconnect; registries per code version; reload: compatible (no wait, pinned roots), prepare isolation (writes throw, failing factory leaves everything unchanged), state bump with migrate and gate, upgrade_conflict for late old roots, deadline abort, exclusive resource replacement, cross-host commit ordering, two-version retirement and superseded.

12. Known limitations and decisions

Still open

  • Retirement across hosts. A child retires a code version only after its parent retired it (retire frame) and its own roots of that code version ended; the server decides from its own roots. A child that comes in step (hello or reconciliation) is told which code versions the parent retired before. A root started on a worker that calls up to a server which has already retired the code version both had still gets superseded (retryable).
  • The commit decision is not persisted (section 7, step 5). A server crash between its own commit and a child's commit leaves that child on the old code version until it reconnects and is reconciled.
  • One level of reload participants. Only the coordinator's direct child hosts take part; a child does not forward prepare to its own children.
  • Reconciliation needs the child's module list. It is remembered from cluster reloads (host.remoteModules), so a server restart forgets it, and a child behind a restarted server then reloads no modules: it gets the code version id with its own code. The reconciliation vote deadline is fixed at 30 s; a failed reconciliation closes the link, so the host manager restarts the worker.
  • Links do not resume. The provider releases everything held through a closed link at once, so re-held objects are new ones. Keeping references for a reconnect window would need session ids on links. Calls in flight when a link drops reject unavailable; whether they ran is unknown.
  • Refused clients cannot tell. The WebSocket adapter closes unauthenticated sockets; the client sees a dropped upstream and its calls wait for a reconnect until their ctx is aborted.
  • The publication queue is process-wide and synchronous. A slow observer delays every later notification in the process, including those of other hosts in the same process.
  • No automatic restart. A terminal host failure is picked up by the host manager's keepAlive or fatal path, not inside Chord.
  • The Node loader leaks. Every reload imports fresh module copies that are never evicted, and it does not isolate plugins (TODO(loader)).
  • Stale socket locks. On Unix systems other than Linux, two processes that probe the same stale socket file at the same moment could both take the lock. Managers serialize starts per lock name.
  • Listener select identity. A listener's subscription is kept across factory runs when its origin and select are unchanged; select counts as unchanged when identical or with the same source text, so a select whose implementation depends on captured values that change between runs is not detected.
  • Shutdown and dead links. host.close() disposes objects in waves (referrers before what they reference) and closes links last. A close that awaits a remote call over a link that is already down waits for a reconnect; bound such calls.

Decisions

  • Errors. The guide's codes; a reason field carries the detail, for example invalid/args, invalid/cycle, invalid/duplicate, invalid/read_only, invalid/not_host_lifetime, invalid/registry_conflict, or unavailable/unknown_type. A ChordError thrown by a factory or a dependency keeps its code through every dependent; other errors become unavailable, with the original as cause.
  • Reload errors. There is no reload_aborted code. A failed reload throws the cause's code and reason with the message reload aborted: … (the cause is attached). Reasons of its own are aborted/gate_deadline (with the blocking roots in details), aborted/deadline, aborted/vote_deadline and unavailable/reload_running. Across hosts, the message names the host that failed. A child cannot reload on its own (unavailable/not_coordinator).
  • New objects during prepare. A reload pre-allocates the next code version ("pending"). Types the same reload adds are served from it; their objects install implementation only for the pending code version and are force-disposed if the reload aborts.
  • Gate admission. From the gate until commit or abort, creating a new object of a changed type fails with upgrade_conflict (retryable, reason gate); nothing is paused, so nothing can deadlock. Gated objects get minCodeVersion = E': calls and writes carrying an older code version are rejected upgrade_conflict permanently, and a gated object drops its previous-code-version implementation at commit. Only objects whose cell versions or exclusive resources change are gated.
  • Tasks and listeners during the gate. Tasks of gated objects are aborted at the admission cut and restart with new code at commit, or with the old code on abort. Listener events for gated objects are queued and replayed through the new listener at commit, or the old one at abort.
  • Grandfathered roots are the roots that called into the object and are still running at the cut. Every host remembers on which links a root's calls went out and sends root_end on them when the root ends, so leases outlive calls across hosts.
  • Removed types and missed retirement. Objects of removed types are force-disposed at commit. When a retirement deadline is missed, retirement is forced: those roots are aborted first (their ctx signal, reason superseded; a relayed call's root cancels its downstream call), then what only that code version declared is closed, and their next call is superseded.
  • Joining during a reload. Participants are the child hosts in step when a reload starts. A child whose hello arrives while it runs is decided when it ends: in step, or reconciled to the committed code version; a child already behind is fenced at once, so the calls its roots make meanwhile are admitted in the parent's code version. A reconciliation that is running when a reload starts syncs its child only after that reload ended, against the code version it committed. A reconciliation announces the parent's retired code versions before its prepare.
  • Two-version rule. An object of a changed type created after a commit has only new code; a previous-code-version root calling it gets superseded.
  • Validation. Remote calls are validated at reference dispatch, in-host too: a direct binding against the provider's schema, a binding through an adapter against the consumer's. A provider validates each snapshot and reset of a remote state member once per (re)subscription, against the consumer's contract through an accepts adapter and the provider's otherwise; ops are validated only in dev mode. At reload, the new provider's description must cover the old provider's when the version is unchanged, in addition to the check against live consumers and facets' uses.
  • Contracts. Local contracts list their members, so a loading reference knows whether a member is a method, state or opaque. Canonical identity omits input fields equal to their default; with TypeBox, use Type.Optional(Type.X({ default })) to let callers omit them. Provider variants must cover consumer variants; members the consumer's contract does not list are hidden. Schema change detection compares canonical JSON, so reordering anyOf branches counts as a change. Fingerprints use a non-cryptographic 64-bit hash: synchronous and browser-safe; contracts are trusted.
  • Principals. A root ctx at ingress acts as its principal (host.root(fn, { principal })); contexts inside objects act as host. The acceptor decides a link's kind and name (accept(link, { principal, kind, name })); only configured host links are addressable, carry roots and may vote, and control frames are accepted only from the parent. Clients never carry roots or code versions. Relaying needs a matching route; authorize(principal, hostName) runs before resolve and again after, and revoking or re-stamping a principal closes its relays at once. invalidatePrincipal(match) re-stamps or closes matching links and propagates pattern matches to child hosts.
  • Cells and readables. A value returned from change replaces the value and is always validated; replace transfers ownership without copying; by default ({ diff } overrides) it publishes a diff when the new value reuses a container of the old one at the same key, else r. subscribe(fn) delivers values, starting with the current one; events(fn) delivers snap, then ops, or reset. Reference state members are stable readables that throw not_ready while loading and not_in_variant outside the variant, and reset when their source moves; a listener added before they are readable gets its snapshot then. Remote state members stay hydrated while the reference is held.
  • References and calls. A reference's identity is its address; a second self.get of the same address in one run returns the same reference. A reference call without ctx, or with a task ctx, starts a fresh root in the current code version; a ctx whose call or root already settled is refused (aborted/expired), one from another host invalid/foreign_ctx. Calling a reference inside a factory throws not_ready.
  • Resources and disposal. New and replaced exclusive resources open at the gate, and so do non-exclusive resources of the same object that depend on them. A new object for an address that is still being disposed waits for that disposal. A resource open that completes after its signal aborted closes its value at once.
  • Links and the host manager. Every received frame is validated against its schema; malformed frames, duplicate ids and control frames from the wrong side close the link. Each connection pings every pingInterval and closes after pingTimeout without a frame. The child holds a kernel lock (an abstract socket on Linux and Android, a named pipe on Windows, a probed socket file elsewhere), acquired by childControl().lock() before storage opens. The manager kills the child's process group on every exit path and derives booting and idleFor from the child's liveness reports. WebSocket support is structural, so Chord does not depend on ws.
  • Flow control holds back state only. A relay (Cloudflare to a browser) can hide a slow reader, so transport backpressure cannot be relied on; acknowledgements are end to end. Results must not wait behind state, so links are FIFO except that waiting state frames can be overtaken: a result may arrive before state changes made before it. Frames of one subscription stay in order. Waiting state is coalesced rather than queued and capped per link, so a stalled peer costs bounded memory, and the next frame it gets is current. The first unit of a new reference is not held back, so a get never waits behind other streams.
  • The state API stays. Objects keep named cells and unnamed derivations; giving each object exactly one state value was rejected. Do not reopen this without a new, concrete problem.

Packages Chord Docs Tutorial (`packages/chord/docs/tutorial.md`)

Build a shared todo list with Chord

1. What problem does Chord solve?

A todo list has data, operations on that data, and several people looking at it.
You could hand-write object creation, subscription cleanup, messages between
processes, and code replacement. Chord connects these pieces for you.

A host is the runtime that owns objects. An object combines state with
methods at an address, such as “the family todo list.” A reference is a caller's
reference to that object, not the object itself. Several references can reach the
same object. References can also reach another host over a connection.

text
browser host                         server host
ref + subscribed rows -- link --> Todos view (owner or guest)
                                           |
                                        ref
                                           v
                                   TodoStore: rows + methods

We will start inside one host, add observable state, then share it with a
browser. Finally we will add background work, plugins, and hot reload.
Live state means subscribers receive new values when data changes, without
asking for it again.

ts
const host = createHost({ self: "server", name: "todo-server" });
assert.equal(host.name, "todo-server");
await host.close();

The assertion passes: the host's name is todo-server. Creating a host does
not create a todo object, start a server socket, or persist data. close()
shuts down its objects and connections.

Tip: These are executable TypeScript examples, using assertions to show
results. Shared imports are at the end. Later sections reuse earlier contracts
and functions, but each section's test starts with fresh hosts. Test-only
cleanup closes hosts even when an assertion fails; snippets omit that cleanup.

2. Your first object

Before callers can use an object, they need to agree on its name and members.
That agreement is a contract: a runtime value that also gives TypeScript its
types. A schema describes a value's shape. We use TypeBox's Type to write
schemas. method(args, result) declares a method by its argument and result
schemas: method() takes no arguments and returns nothing, and
method(undefined, Type.String()), used below, takes no arguments and returns a
string.

Start with a label for a named list. local means only code inside this host
can reach it. The input { list: "family" } identifies which list we want;
it is not an argument to label.

ts
const TodoLabel = local("tutorial.todos", {
	version: 1,
	input: Type.Object({ list: Type.String() }),
	members: { label: method(undefined, Type.String()) },
});

A factory is a synchronous function that creates the object's implementation:
the methods and state callers will see. serve registers a contract and its
factory. Registration alone does not activate this lazy object; the first
get does.

A root is one short tree of calls, such as the work for a button click.
host.root starts it. Its context, called ctx, carries cancellation,
caller identity, and the code version for nested calls. Pass it to methods you
call from that root.

ts
const host = createHost({ self: "server" });
host.serve(TodoLabel, ({ list }) => ({ label: () => `Todos for ${list}` }));
const result = await host.root(async (ctx) => {
	const todos = await ctx.get(TodoLabel, { list: "family" });
	return todos.label(ctx);
});
assert.equal(result, "Todos for family");

The result is "Todos for family". await ctx.get resolves once the object is
live (and, as we will see, has its state). The reference acquired through
ctx.get is released when this root ends. We never receive the factory's
returned object directly. A standalone call without ctx, such as
todos.label(), starts its own root. A method without arguments takes only
the context: todos.label(ctx); its implementation is (ctx) => …. Inside a root or method, pass the
existing ctx so nested calls stay in the same call tree.

The factory may also receive principal (who is asking) and self (the
object's declaration API). We did not need them yet.

Why: The reference separates callers from implementations. Later, replacing
code will not require replacing every caller's reference.

3. Give the list live state

A label is not a todo list. We need rows that can change, and a count that always
agrees with them. A cell owns an observable JSON value. A derivation
computes another observable value from cells or other readable sources.

Extend the contract with rows, openCount, and editing methods. State
members describe values, not methods. This adds members at contract version 1;
older callers that only need label still have everything they need.

self.state(name, schema, initial) declares a cell and returns it. The name
rows is important: on reload, Chord matches that name to the existing cell and
keeps its value. self.derive(sources, fn) declares a derivation; it has no
name, because it holds nothing a reload must keep.

ts
const Todo = Type.Object({ text: Type.String(), done: Type.Boolean() });
const Rows = Type.Array(Todo);
const TodoStore = local("tutorial.todos", {
	version: 1,
	input: Type.Object({ list: Type.String() }),
	members: {
		label: method(undefined, Type.String()),
		rows: Rows,
		openCount: Type.Number(),
		add: method(Type.Object({ text: Type.String() })),
		finish: method(Type.Object({ index: Type.Integer() })),
		reset: method(Rows),
	},
});

The factory now declares a cell and a derivation. It is wrapped in todoStore,
which takes the cell's options (section 9 raises its version); for now
todoFactory uses the defaults. Each method receives its arguments and a
context; changing the cell updates its readers and its derived count together.
add, finish and reset declare no result, so they return nothing.

ts
/** The store's factory; `options` versions its rows (section 9). */
const todoStore =
	(options?: StateOptions<Static<typeof Rows>>): Factory<typeof TodoStore> =>
	({ list }, _principal, self) => {
		const rows = self.state("rows", Rows, [], options);
		const openCount = self.derive([rows], (todos) => todos.filter((todo) => !todo.done).length);
		return {
			label: () => `Todos for ${list}`,
			rows,
			openCount,
			add: ({ text }, ctx) => {
				rows.change(ctx, (draft) => {
					draft.push({ text, done: false });
				});
			},
			finish: ({ index }, ctx) => {
				rows.change(ctx, (draft) => {
					const todo = draft[index];
					if (!todo) throw invalid("args", "No such todo");
					todo.done = true;
				});
			},
			reset: (next, ctx) => {
				rows.replace(ctx, next, { diff: true });
			},
		};
	};
const todoFactory = todoStore();

change(ctx, fn) gives fn a temporary mutable draft. Here add
pushes a row and finish changes one field. The draft is discarded if the
callback throws. The callback must be synchronous.

replace(ctx, value) replaces the whole value. We use it for resetting a
list we own completely. { diff: true } asks Chord to publish just the
structural changes. Without that option, replacement diffs automatically when
the new value shares containers at the same keys with the old one; unrelated
values normally publish a whole-value replacement. Either way, subscribers
receive the same resulting value.

ts
const host = createHost({ self: "server", dev: true });
host.serve(TodoStore, todoFactory);
const todos = await host.get(TodoStore, { list: "family" });
const counts: (number | undefined)[] = [];
const stop = todos.openCount.subscribe((value) => counts.push(value));
await host.root(async (ctx) => {
	await todos.add({ text: "Buy milk" }, ctx);
	await todos.finish({ index: 0 }, ctx);
	await todos.reset([], ctx);
});
assert.deepEqual(counts, [0, 1, 0]);
assert.deepEqual(todos.rows.value, []);
stop();
todos.release();

The count subscriber sees 0, 1, 0: initially empty, one unfinished todo,
then no unfinished todos. Resetting to empty does not add another count
notification because the derived count is still zero. The final rows are [].
subscribe calls back immediately with the current value and returns an
unsubscribe function. The awaited get makes sure there is a value: a reference
you hold is live, with its state.

Why: Store reloadable state in cells, not closure variables. Treat
cell.value, ref.rows.value, and subscriber values as immutable.
Only mutate drafts inside change; do not keep a draft for later use.
A derivation runs synchronously, so use it for projections, not database work.

4. Let objects use objects and resources

Saving a todo may require both the list and a database connection. A
reference keeps the object it refers to alive. self.get declares a
reference owned by this object; it lasts longer than a single method call.

A resource is something the runtime opens and later closes, such as a
database connection. Its open may be asynchronous even though the factory
must be synchronous. The example database is deliberately fake: it just saves
strings in memory.

opaque<T>("name@version") describes a native, host-only member. It is not
JSON state and cannot be sent over a link. self.expose connects that member
to a declared resource, without reading the resource during the factory.

ts
const Database = local("tutorial.database", {
	version: 1,
	input: Type.Object({}),
	members: { db: opaque<{ saved: string[] }>("tutorial.database@1") },
});
const SavedTodos = local("tutorial.saved-todos", {
	version: 1,
	input: Type.Object({ list: Type.String() }),
	members: { add: method(Type.Object({ text: Type.String() })) },
});
const host = createHost({ self: "server" });
let opens = 0;
const closed = Promise.withResolvers<void>();
host.serve(Database, (_input, _principal, self) => {
	const db = self.resource("db", {
		version: 1,
		open: async () => {
			opens++;
			return { saved: [] as string[] };
		},
		close: () => closed.resolve(),
	});
	return { db: self.expose(db) };
});

The factory returns an exposed marker, not an opened connection. Chord runs
open after declarations finish. The promise named closed lets the test
observe eventual cleanup.

Now declare SavedTodos as an object that holds both the store and the database.
Its editing method uses these dependencies only after activation.

ts
host.serve(TodoStore, todoFactory);
host.serve(
	SavedTodos,
	({ list }, _principal, self) => {
		const database = self.get(Database, {});
		const todos = self.get(TodoStore, { list });
		return {
			add: async ({ text }, ctx) => {
				database.db!.saved.push(text);
				await todos.add({ text }, ctx);
			},
		};
	},
	{ linger: 0 },
);

An outside consumer holds a SavedTodos reference. We also hold Database
directly so we can inspect what was saved.

ts
const saved = await host.get(SavedTodos, { list: "family" });
const database = await host.get(Database, {});
await host.root((ctx) => saved.add({ text: "Buy milk" }, ctx));
assert.deepEqual(database.db!.saved, ["Buy milk"]);
assert.equal(opens, 1);
const statuses: string[] = [];
saved.onStatus((status) => statuses.push(status));
database.release();
saved.release();
await closed.promise;
assert.equal(saved.status, "released");
assert.ok(statuses.includes("released"));

After the method, the fake database contains ["Buy milk"]. It opened once,
even though two objects referenced it. SavedTodos and Database become live
only after their resources and declared dependencies are ready. That is why
database.db! is safe inside this live object's method: activation has
already established that dependency. An outside caller awaits its get.

When both external references are released, SavedTodos is disposed and
releases its own database reference. With no references left, the database closes; we
wait for closed.promise to prove it. The released reference stays
released, and onStatus reports that transition. onStatus watches
changes; unlike state subscription, it does not deliver an initial status.

linger is a delay in milliseconds after the last reference is released. Zero disposes
without a grace period; a positive value can avoid reopening an expensive
resource when a screen is briefly hidden. Linger is not persistence.

Tip: A factory only declares dependencies. Never call a referenced object's method or
read resource.value there, including during reload. Activation happens
afterward. Bump a resource's version when its configuration should be reopened.
Disposal stops tasks and listeners, closes resources, then releases references.

5. Who is asking? One store, two views

Owners may add todos; guests may only read them. A principal is the
application-defined identity of a caller. A variant is a named set of
members a remote object offers to that principal. It lets the UI discover
whether an editing method is available.

Do not create a separate data cell for each viewer: their lists would diverge.
Instead, each view holds the same host-local TodoStore and exposes its rows.
self.get acts as the host's own principal, so owner and guest views share
that store. Remote view objects themselves are separate per principal key.
A remote contract also says where its objects live: host: hostNamed("server")
puts every Todos object on the host named server. Section 6 uses that.

ts
const Todos = remote("tutorial.todo-view", {
	version: 1,
	input: Type.Object({ list: Type.String() }),
	host: hostNamed("server"),
	variants: {
		viewer: { state: { rows: Rows } },
		editor: {
			state: { rows: Rows },
			methods: { add: method(Type.Object({ text: Type.String() })) },
		},
	},
});

/** Who may ask: the owner, a guest, or the server's own code. */
type Principal = "owner" | "guest" | "server";

function serveViews(api: Pick<Host<Principal>, "serve">): void {
	api.serve(Todos, ({ list }, principal, self) => {
		if (list !== "family") throw forbidden("This list is not shared");
		const store = self.get(TodoStore, { list });
		if (principal === "guest") {
			return { variant: "viewer", rows: store.rows };
		}
		return {
			variant: "editor",
			rows: store.rows,
			add: ({ text }, ctx) => {
				if (text.trim().length === 0) throw invalid("args", "A todo needs text");
				return store.add({ text }, ctx);
			},
		};
	});
}

Here principals are the strings "owner" and "guest", assigned by
trusted application code, and "server", the host's own code: every host is
created with self, the principal its own roots and objects act as. Chord
invents no principals. The Principal type parameter of createHost and
Host makes principal in the factory that type, so TypeScript checks the
comparisons. principalKey maps identity to object identity. Real
applications should use stable identity, connection provenance, and grant
revision, not display names.

ts
const host = createHost<Principal>({ name: "server", self: "server", principalKey: (principal) => principal });
host.serve(TodoStore, todoFactory);
serveViews(host);
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
const guest = await host.get(Todos, { list: "family" }, { principal: "guest" });
assert.ok(owner.variant === "editor"); // narrows the reference to the editor variant
assert.equal(guest.variant, "viewer");
await host.root((ctx) => owner.add({ text: "Buy milk" }, ctx), { principal: "owner" });
assert.deepEqual(guest.rows.value, [{ text: "Buy milk", done: false }]);
// @ts-expect-error A viewer has no add: TypeScript refuses it, and so does the provider.
await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant"));
await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "invalid"));

The owner gets editor; the guest gets viewer. Both see the same milk
todo. A reference is typed as the union of its variants: add is reachable only
after narrowing on variant (assert.ok(owner.variant === "editor") does that).
The guest's add is refused by TypeScript, and, called anyway, rejects with
not_in_variant. Whitespace-only text
rejects with invalid: it is a string, as the schema demands, but it breaks a
rule only the method knows. Use invalid for bad input and forbidden only
for callers who may not do something.

Why: A variant is not all of authorization. The factory checks which
list may be opened; the method checks the operation's arguments. Production
code must also enforce grants that can change. Use
host.invalidatePrincipal(match) to revoke matching objects; held references
become gone. A UI watches onStatus and gets a new reference when appropriate.
Guest-provided names or message text must never establish identity.

6. Reach the list from another process

A browser cannot call a host-only contract. remote contracts describe only
schema-checked state and methods that can travel as JSON. Our Todos view is
remote; the underlying TodoStore and database stay local. A caller never
names a host: browser.get(Todos, …) goes to server because the contract
says so, and the server refuses to serve, or to be asked for, a type it is not
the place of. A type that lives on one host per session says
host: hostFamily("session:", "session"): its input's session field picks
the host ({ session: "S1" } lives on session:S1).

A link carries ordered messages between hosts. A memory link runs both ends
in one test process, but uses the remote protocol. The accepting host stamps
the caller's principal from its authenticator, not from method arguments or a
client's claimed identity. The connecting side names the principal its parent's
own requests act as (upstream: { link, principal }, or connect(link, { principal })).

ts
const server = createHost<Principal>({ self: "server", name: "server" });
server.serve(TodoStore, todoFactory);
serveViews(server);
const [down, up] = memoryLinkPair();
server.accept(down, { principal: "owner", kind: "client" });
const browser = createHost({
	self: "browser",
	name: "browser",
	kind: "client",
	upstream: { link: up, principal: "server" },
});
const todos = await browser.get(Todos, { list: "family" });
assert.ok(todos.variant === "editor");
assert.deepEqual(todos.rows.value, []);
const updated = Promise.withResolvers<void>();
const stop = todos.rows.subscribe((rows) => {
	if (rows.length === 1) updated.resolve();
});
await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));
await updated.promise;
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
// Deliberately bypass TypeScript to simulate a malformed caller.
await assert.rejects(todos.add({ text: 123 } as never), (error) => isChordError(error, "invalid"));
stop();
// A held reference keeps its state without listeners.
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
todos.release();

The browser sees an empty snapshot, then the new milk todo. ready means
the remote reference has bound to a live object and has its state: every
state member of its variant has a snapshot. A held reference stays subscribed,
with or without listeners, until it is released.

What crosses the wire: the input and consumer's contract description, the
provider's shape (variant and members), method arguments and results, and the
state of the reference's variant. State starts with a snapshot, the whole current value;
subsequent deltas describe changes. Rebinds and reloads can send a
reset, a fresh whole value. Functions, resource connections, and cell
instances do not cross the wire.

The deliberate bad argument rejects with invalid. Remote arguments,
results, and outgoing state are validated at the provider boundary. Local
methods do not get per-call schema validation; they still need app-level checks.
dev: true additionally checks cell writes while developing.

Tip: Replace memoryLinkPair with webSocketLink for a WebSocket or
ipcLink for a child-process IPC channel; object contracts stay the same.
Those adapters wrap an existing connection, not an authenticator. Configure
accept with an authenticated principal. A browser is kind: "client";
a trusted child host is accepted with kind: "host" and its configured name.

7. React to changes and run background work

Not all work comes from method calls. The app may update an activity indicator
when rows change, or run a background reminder. A listener reacts to an
observable source. A task is background execution owned by an object,
which is aborted when the object is disposed or its code is reloaded.

Our activity object mirrors the row count. Its task inserts a sample todo,
then waits for shutdown. The promises make those events deterministic in a
test; they are not stored application state.

ts
const Activity = local("tutorial.activity", {
	version: 1,
	input: Type.Object({}),
	members: { seen: Type.Number() },
});
const host = createHost({ self: "server" });
host.serve(TodoStore, todoFactory);
const observed = Promise.withResolvers<void>();
const seeded = Promise.withResolvers<void>();
const stopped = Promise.withResolvers<void>();
host.serve(Activity, (_input, _principal, self) => {
	const todos = self.get(TodoStore, { list: "family" });
	const seen = self.state("seen", Type.Number(), 0);
	self.on("rows", todos.rows, (rows, ctx) => {
		seen.replace(ctx, rows?.length ?? 0);
		if (rows?.length === 1) observed.resolve();
	});
	self.task(async (ctx) => {
		await todos.add({ text: "Review the list" }, ctx);
		seeded.resolve();
		await new Promise<void>((resolve) => {
			if (ctx.signal.aborted) resolve();
			else ctx.signal.addEventListener("abort", () => resolve(), { once: true });
		});
		stopped.resolve();
	});
	return { seen };
});
const activity = await host.get(Activity, {});
await Promise.all([seeded.promise, observed.promise]);
assert.equal(activity.seen.value, 1);
activity.release();
await stopped.promise;

The final seen is 1. Each listener event receives a fresh root's
ctx; its write belongs to that short call tree. A task is not one enormous
root: Chord method calls made with its task context start fresh short roots.
We pass that context into todos.add so cancellation follows the task.
Releasing the activity reference aborts its task and resolves stopped.

self.on(name, source, handler) declares a listener; self.task(fn) a task.
A listener on state, like rows here, gets the current value when it starts,
then every change. It gets the current value again whenever a reload replaced
its source (a derivation is new at every reload), so its handler must
reconcile (seen is set from the rows, not counted up). Listeners can also
subscribe to a resource's event stream with self.on(name, resource, select, handler): select gets the opened resource and returns something with
subscribe; an event stream delivers no current value. Kept subscriptions
survive reload while the resource and selector remain unchanged; the handler
switches to new code. A selector that captures changing configuration should
get a new listener name. Tasks have no names: a reload restarts them all.

Why: Do not wait for a person, sleep for minutes, or run an endless loop
inside a method. That pins a root and can block state migration. Store a
pending request in a cell and answer in another call, or use a task that obeys
ctx.signal. Never retain a completed method's ctx for background work.

8. Package features as plugins

Now a plugin can contribute a menu action without editing a central switch.
A registry is a named table of contributions. A facet is a plugin's code
for one kind of host: it serves object types. A plugin can have server, worker, and browser
facets, each installing that part of the feature.

Registrations belong to objects of lifetime "host": the host creates such
an object when its facet loads and keeps it until the host closes, even if no
screen asks for it. That keeps our menu contribution present. Objects of the
default lifetime, "referenced", are still created only when referenced.

ts
const Actions = defineRegistry<(ctx: Ctx) => Promise<number>>("tutorial.actions");
const Menu = local("tutorial.menu", { version: 1, input: Type.Object({}) });
let click: (() => Promise<number>) | undefined;
const storeFacet: Facet = {
	name: "todo-store",
	...defineFacet((api) => api.serve(TodoStore, todoFactory)),
};
const menuFacet: Facet = {
	name: "todo-menu",
	...defineFacet(
		(api) => {
			api.serve(
				Menu,
				(_input, _principal, self) => {
					const todos = self.get(TodoStore, { list: "family" });
					self.register(Actions, {
						addSample: async (ctx) => {
							await todos.add({ text: "Try plugins" }, ctx);
							return todos.openCount.value;
						},
					});
					return {};
				},
				{ lifetime: "host" },
			);
			click = () => api.root((ctx) => api.registry(Actions).get("addSample", ctx)!(ctx));
		},
		{ uses: [TodoStore] },
	),
};
const host = createHost({ self: "server" });
await host.load([menuFacet, storeFacet], { awaitHostObjects: false });
await host.ready();
assert.equal(await click!(), 1);
assert.equal(host.registry(Actions).table().size, 1);

Clicking returns 1, and the registry has one entry. The menu's dependency
can come from a later facet in the load batch: load installs the whole
batch before waiting for host objects to activate. The facet declares uses so
reload can also check the contract it consumes.

The facet API supplies serve, name, root, and registry.
Our ordinary UI callback uses its saved api.root, not a process-global
host. Looking up a registry entry with ctx uses the table belonging to that
root's code version. Without ctx, lookup uses the current table. Duplicate
keys fail by default; subscribe can bridge tables to an imperative consumer.

Normally use await host.load(facets) (or await host.install(facet)),
which waits for those host objects. This example explicitly defers that barrier
with awaitHostObjects: false, then awaits host.ready(). That waits for a
snapshot of the host's objects, not for task completion or later loads.
Failures reject the readiness promise; abandoning startup requires closing the
host, because installation is not a rollback transaction.

Tip: Use deferred readiness only when needed. A worker may have to await
an independent local bootstrap reference, announce control readiness so its
parent admits its link, and only then await host.ready(). Waiting for
upstream-dependent host objects before admission would deadlock.

For real plugin files, default-export defineFacet(...). Node hosts configure
createHost({ loader: nodeLoader }), with nodeLoader imported from
@earendil-works/chord/node, then pass module URLs to load and reload.
The in-memory facet objects here avoid filesystem setup. Built-in facets are
trusted in-process code, not a sandbox; the current Node loader keeps old module
copies in memory, so repeated loading has that limitation.

9. Change running code without losing the list

A reference is useful precisely because callers keep it while implementations
change. An code version identifies one committed set of code and registry tables.
A root keeps the code version it started with. New roots use the latest one.

Compatible code change: no waiting

Give the same named facet a new factory. It changes the label but keeps the
cell declaration. This test deliberately pauses one caller to show what an
old root sees; application methods should not pause like this.

ts
const host = createHost({ self: "server" });
const facet = (label: string): Facet => ({
	name: "todos",
	setup: (api) =>
		api.serve(TodoStore, (input, principal, self) => ({
			...todoFactory(input, principal, self),
			label: () => label,
		})),
});
await host.load([facet("Original")]);
const todos = await host.get(TodoStore, { list: "family" });
await todos.add({ text: "Buy milk" });
const entered = Promise.withResolvers<void>();
const resume = Promise.withResolvers<void>();
const oldRoot = host.root(async (ctx) => {
	const first = await todos.label(ctx);
	entered.resolve();
	await resume.promise; // Test-only pause: pin an old caller during reload.
	return [first, await todos.label(ctx)];
});
await entered.promise;
const compatible = await host.reload([facet("Updated")]);
assert.deepEqual(compatible.gated, []);
assert.equal(await todos.label(), "Updated");
resume.resolve();
assert.deepEqual(await oldRoot, ["Original", "Original"]);
assert.equal(todos.rows.value?.[0]?.text, "Buy milk");

Reload commits while the old root is paused: gated is empty. New calls see
"Updated"; the resumed old root sees "Original" twice. The milk row
survives. Compatible code changes do not wait for that old root to finish.

State change: bump its version and migrate

Suppose a new policy converts existing todo text to uppercase. The schema is
unchanged, but the data's meaning changes. We still bump the cell's state
version
(self.state's version option, default 1). It belongs to one cell,
independently of the contract version. A migration (migrate) converts an
old value of that cell to the new representation.

ts
const events: string[] = [];
const stopEvents = todos.rows.events((event) => events.push(event.type));
const migrated = await host.reload([
	{
		name: "todos",
		setup: (api) =>
			api.serve(
				TodoStore,
				todoStore({
					version: 2,
					migrate: (old, from) => {
						assert.equal(from, 1);
						return (old as Static<typeof Rows>).map((todo) => ({ ...todo, text: todo.text.toUpperCase() }));
					},
				}),
			),
	},
]);
assert.equal(migrated.gated.length, 1);
assert.deepEqual(todos.rows.value, [{ text: "BUY MILK", done: false }]);
assert.deepEqual(events, ["snap", "reset"]);
stopEvents();

The migration receives the old rows and their state version, 1. It returns
new rows; the reference now reads [{ text: "BUY MILK", done: false }].
Subscribers get a reset event containing the migrated value. The event
assertion sees an initial snap, then that reset.
One object is gated. A gate waits for calls already inside that object to
finish before migrating a private copy, validating it, and committing. Old
roots that had not entered it cannot later use old code over migrated data:
they get upgrade_conflict and should retry in a new root.

Changing a cell's schema also requires a version bump, and a migration unless
the old value already fits the new schema. A change of meaning with the same schema,
like this example, cannot be detected automatically. Real migrations must
reference each stored version they support, not assume every installation is
exactly one version behind.

Contract change: keep an adapter for old callers

Rename label to heading. That is a breaking contract change, so use
contract version 2. The provider's accepts[1] adapter builds the implementation
that version-1 consumers still expect.

ts
// Remove label from the new provider's contract: it is now called heading.
const { label: _label, ...members } = TodoStore.def.members;
const RenamedStore = local("tutorial.todos", {
	version: 2,
	input: TodoStore.def.input,
	members: { ...members, heading: method(undefined, Type.String()) },
});
await host.reload([
	{
		name: "todos",
		setup: (api) =>
			api.serve(
				RenamedStore,
				(input, principal, self) => {
					const { label, ...implementation } = todoStore({ version: 2 })(input, principal, self);
					return { ...implementation, heading: label };
				},
				{
					accepts: {
						1: (current) => ({ ...current, label: current.heading }),
					},
				},
			),
	},
]);
assert.equal(await todos.label(), "Todos for family");
const newer = await host.get(RenamedStore, { list: "family" });
assert.equal(await newer.heading(), "Todos for family");
assert.equal(todos.rows.value?.[0]?.text, "BUY MILK");

The old reference's label and the new reference's heading both return
"Todos for family". Both still reach the migrated rows. The adapter maps
implementations, not references; forwarding the existing method preserves its context.
We keep the cell at version 2 here so this code-only change does not repeat
migration.

Why: Contract versions protect callers; state versions protect stored
data; resource versions control reopening. Do not use one number for all
three. Additive compatible contracts can keep their version; breaking shape
or meaning changes need a contract bump and adapters for old consumers.
Reload prepares candidates first; preparation failure leaves the old code
running. A migration gate that misses its deadline aborts rather than
switching data underneath active old code.

10. Test the objects, not a whole deployment

Most implementation needs only a bare host. Use real factories for state tests, and a
fake facet when you want to test delegation without a real database or service.
Here the fake records what the editing view sends to the store.

ts
const host = createHost<Principal>({ name: "server", self: "server", dev: true });
const received: string[] = [];
const fake: Facet = {
	name: "fake-store",
	...defineFacet((api: FacetApi) => {
		api.serve(TodoStore, (input, principal, self) => ({
			...todoFactory(input, principal, self),
			add: ({ text }) => {
				received.push(text);
			},
		}));
	}),
};
await host.load([fake]);
serveViews(host);
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
assert.ok(owner.variant === "editor");
await owner.add({ text: "Test delegation" });
assert.deepEqual(received, ["Test delegation"]);
await assert.rejects(owner.add({ text: "" }), (error) => isChordError(error, "invalid"));
owner.release();
await assert.rejects(owner.add({ text: "Too late" }), (error) => isChordError(error, "released"));

The fake received ["Test delegation"]. Empty text failed before reaching
the fake. The released reference rejects further calls with released.
isChordError(error, code) checks a stable error code; avoid testing only a
message's wording.

Our other tests exercise the real cells (section 3), resource cleanup (4),
principal variants (5), a client over a memory link (6), and callers kept through
reload (9). After an awaited get the state is there; for later changes, await
the value you need. Always unsubscribe and close hosts; leaked references can hide
lifetime bugs or leave background tasks running.

Tip: A fake must honor the contract it serves. This fake delegates all
ordinary members to todoFactory and replaces only add; it deliberately
objects calls rather than updating rows. Do not confuse that with persistence.

11. Keep these habits

A temporary call reference should not accidentally become a permanent one. A
screen should not lose its data merely because a button click finishes. Use
ctx.get for the first lifetime and host.get for the second.

ts
const host = createHost({ self: "server" });
host.serve(TodoStore, todoFactory);
const screen = await host.get(TodoStore, { list: "family" });
await host.root(async (ctx) => {
	const temporary = await ctx.get(TodoStore, { list: "family" });
	await temporary.add({ text: "Keep roots short" }, ctx);
}); // The temporary reference is released here; screen still holds the store.
assert.equal(screen.openCount.value, 1);
screen.release(); // No reference remains; without persistence, disposal loses these rows.

The screen still sees count 1 after the root finishes, because it owns a
separate reference. When the screen releases it, no reference remains. This tutorial's cells
are in memory: reload preserves a live object, but disposal or process restart
loses its rows. Real persistence belongs in a database resource, with an
explicit load/save policy.

Checklist and common mistakes

  • Keep reloadable state in cells, not closure variables: factories rerun.
  • Change drafts, not published values: mutation outside cells bypasses updates.
  • Use change for field edits and replace for owned snapshots: replacing
    can erase fields your code does not know about.
  • Declare dependencies in a synchronous factory, not during methods: activation
    and reload need a complete declaration list.
  • Use ctx.get for temporary method dependencies: their references end with the call.
  • Pass ctx into nested calls: cancellation and code versions must stay together.
  • Keep roots short and tasks cancellable: migration and shutdown need work to settle.
  • Check grants and arguments, not just variants: available methods are not a full policy.
  • Stamp principals at trusted ingress, not from client payloads: names are not identities.
  • Await get before reading state: a reference you hold is live and hydrated, a
    loading one (host.hold) throws not_ready.
  • Release references and unsubscribe: otherwise unused objects remain referenced or observed.
  • Bump state versions, contract versions, and resource versions for their own changes:
    each protects a different boundary.
  • Await host object readiness before reporting startup success: declared is not yet live.
  • Keep reference dependencies acyclic: an object cannot wait for itself through another object.

Imports and running the examples

These are the shared imports from test/tutorial.test.ts. The relative
path is correct from that test directory; applications import the same names
from @earendil-works/chord. assert expresses expected values; it and
onTestFinished come from the test runner; readFileSync is used by the
tutorial's source-matching check, not by the application.

ts
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { type Static, Type } from "typebox";
import { it, onTestFinished } from "vitest";
import {
	type Ctx,
	createHost,
	defineFacet,
	defineRegistry,
	type Facet,
	type FacetApi,
	type Factory,
	forbidden,
	type Host,
	hostNamed,
	invalid,
	isChordError,
	local,
	memoryLinkPair,
	method,
	opaque,
	remote,
	type StateOptions,
} from "../src/index.ts";

From packages/chord, run:

sh
node ../../node_modules/vitest/dist/cli.js --run test/tutorial.test.ts

There is one test per numbered section. Every TypeScript block is matched
against its runnable source, so changing either copy without the other fails a
test. The test harness adds teardown and drift checks; it never uses a real
database, network service, or provider API.

Next read the usage guide for the API reference and
the specification for compatibility, lifetime, and reload
rules, including its known limitations. If a reference disagrees with
src, the current implementation wins.


Packages Coding Agent Docs Cli Integration (`packages/coding-agent/docs/cli-integration.md`)

CLI Integration

By default, running pi opens the interactive terminal interface. When input or output is piped or redirected, Pi uses print mode instead. You can also select print, JSON, or RPC mode explicitly for scripts and applications.

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 CLI mode. It embeds the agent directly in a Node.js or Bun process. See the SDK when direct TypeScript access is preferable to a process boundary.

Choose a mode

Mode Interface Lifetime Use it when
Interactive Terminal UI Until the user exits A person is working with Pi directly
Print Final text on stdout One invocation A script needs the final assistant response
JSON JSONL events on stdout One invocation A process needs structured progress from a run
RPC JSONL commands, responses, and events Long-lived A process needs bidirectional control

CLI options still select the working directory, model, tools, resources, and session persistence independently of the mode. See Command Line for the complete startup options.

Print mode runs the supplied prompts, writes the final assistant text to stdout, and exits:

bash
pi --print "Summarize the changes in this repository"

Use print mode when only the final text is needed, including command substitution, pipelines, and one-shot jobs. Intermediate events are not exposed.

Print mode writes errors to stderr. A final assistant response with an error or aborted stop reason produces a nonzero exit status. A response cut off at the model's output token limit (length stop reason) still prints its partial text to stdout, writes a warning to stderr, and produces a nonzero exit status.

When no mode is selected explicitly, non-TTY stdin or stdout also selects print mode. This allows piped input and output without adding --print.

Stream JSON events

JSON mode writes a session header followed by agent and session events as newline-delimited JSON:

bash
pi --mode json "Review this repository" > events.jsonl

This is structured event output, not a single JSON result or a constraint on the format of the model’s response.

All prompts are supplied when the process starts. The process streams events for that run and then exits; it does not accept later commands.

A failed or aborted assistant response appears in the event stream but does not by itself produce a nonzero exit status. Inspect the events when success or failure matters. Pi still exits nonzero if the invocation throws an error.

Streaming message_update records contain deltas rather than a growing message snapshot. Assemble live output from the delta events, then replace it with the authoritative message from message_end.

agent_end can be followed by automatic recovery or queued work. agent_settled marks the end of automatic work for the current run.

Stdout is reserved for JSONL. Diagnostics and application logging are written to stderr. See JSON Event Stream for framing, event shapes, and reconstruction rules.

Control Pi with RPC

RPC mode keeps Pi running while another process sends commands and receives responses and events:

bash
pi --mode rpc --no-session

Commands are JSON objects written to stdin. Responses and events are JSON objects written to stdout. Every record occupies one line.

Add an id to commands that need correlation. The matching response repeats that ID. Events generally have no command ID because they describe session activity rather than one request.

A successful prompt response means the prompt was accepted, queued, or handled. It does not mean the run completed. Continue consuming events through agent_settled when completion matters.

RPC commands can change models, inspect state, manage sessions, run shell commands, and answer extension UI requests.

Extension dialogs form a request-response subprotocol. Other extension UI updates are notifications that a client may display or ignore. TUI-only extension capabilities are unavailable or degraded outside interactive mode.

For Node.js or TypeScript integrations, prefer RpcClient from @earendil-works/pi-coding-agent. It starts a Pi RPC child process, correlates requests, exposes typed command methods, and delivers session events to listeners.

The RPC client example sends one prompt, streams text and tool activity, waits for agent_settled, and shuts down the child process. It is included in the repository’s TypeScript checks.

RpcClient.promptAndWait() installs its event listener before sending the prompt, avoiding a race with fast completions. For separate operations, subscribe before calling prompt() and call waitForIdle() only while a run is active.

The client requires a path to a runnable Pi CLI. The repository example points at dist/cli.js, so the package must be built before that example runs from a checkout.

If you are building a client without RpcClient, start with RPC Protocol, then use RPC Commands and JSON Event Stream as the wire references.

Fork and rebrand Pi

A source fork can change the CLI name and configuration directory through package.json:

json
{
  "piConfig": {
    "name": "my-agent",
    "configDir": ".my-agent"
  }
}

Change the top-level bin field to set the executable name. These settings affect the CLI banner, configuration paths, and derived environment variable names.

Examples and references


Packages Coding Agent Docs Cli (`packages/coding-agent/docs/cli.md`)

Command Line

This page documents Pi's built-in command-line commands and options. Run pi --help or append --help to a command for the exact interface in your installed version. The top-level help also includes options registered by loaded extensions.

sh
pi [options] [--] [@files...] [messages...]
pi install <source> [options]
pi remove <source> [options]
pi uninstall <source> [options]
pi update [target] [options]
pi list
pi config [options]
pi auth <check|print-api-key|print-bearer-token> [options]
pi mcp <list|login|logout> [options]

Invocation and output

sh
pi
pi --print "Summarize this repository"
git diff | pi --print "Review this change"
pi --mode json "Inspect this repository" > events.jsonl

With terminal stdin and stdout, Pi opens the terminal UI unless --print, --mode json, or --mode rpc selects another interface. When either stream is redirected and neither JSON nor RPC mode is selected, Pi uses print mode. See CLI Integration for choosing between interactive, print, JSON, RPC, and SDK integration.

Input Behavior
message Provide an initial prompt
@path Include a text file or image in the first prompt
Piped stdin Prepend its contents to the first prompt
-- Stop option parsing so a prompt can begin with -

Pi resolves @path from the current working directory. The working directory also controls project configuration, resource discovery, and session grouping.

--print controls whether Pi runs once and exits. --mode selects the output interface. --mode text does not force one-shot execution when stdin and stdout are terminals; use --print for that behavior.

Option Behavior
-p, --print Run the supplied prompts, write the final assistant text to stdout, then exit
--mode text Select text output; still open the terminal UI when stdin and stdout are terminals
--mode json Run the supplied prompts, write JSONL events to stdout, then exit
--mode rpc Read JSONL commands from stdin and write responses and events to stdout until shutdown
--export [output] Export a session file to HTML and exit; derive the destination when output is omitted

RPC mode rejects @file arguments. JSON and RPC modes reserve stdout for protocol records. See JSON Event Stream and RPC Protocol.

Models

sh
pi --model sonnet:high

See Choose a Model for model selection and Providers for credentials.

  • --provider <name>
    Restricts --model lookup to one provider. It requires --model.
  • --model
    Selects by exact ID or fuzzy ID/name match. It accepts provider/id and an optional : suffix. If several providers match, Pi prefers the one with credentials, otherwise it asks for --provider.
  • --api-key <key>
    Uses a non-persistent API-key override. It requires a model selected through --model or --models. It does not choose between providers that offer the same model.
  • --thinking <level>
    Sets off, minimal, low, medium, high, xhigh, or max. It overrides a --model suffix and is clamped to the model's capabilities.
  • --models
    Sets a comma-separated scope for startup and cycling. It accepts exact IDs, fuzzy matches, case-insensitive globs, and optional : suffixes. A model offered by several providers is added for each, with a warning.
  • --list-models [search]
    Lists available models, optionally filtered by a fuzzy search, then exits.

Sessions

sh
pi --continue

See Sessions and Context for resuming, forking, naming, and storing sessions.

  • -c, --continue
    Continues the most recent session for the current project.
  • -r, --resume
    Opens the session selector.
  • --session
    Opens by file path, exact ID, or partial ID. Pi searches the current project first and offers to fork a cross-project match.
  • --session-id
    Opens the exact project session ID or creates it if absent. IDs accept letters, numbers, ., _, and -.
  • --fork
    Forks an existing session into a new session for the current project.
  • `--sess