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:
curl -fsSL https://pi.dev/install.sh | shOn Windows:
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:
npm install --ignore-scripts @earendil-works/pi-coding-agentPi 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:
cd /path/to/project
piFor 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
nix run github:earendil-works/pi/stablestable 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:
npm run update:model-catalog-pinPackages
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
piand provider auth on the host while routing built-in tools and!commands into a local Linux micro-VM. - Plain Docker: run the whole
piprocess in a local container for simple isolation. - OpenShell: run the whole
piprocess 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
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:
npm run pack:packages -- --out .artifacts/pi-packagesThis 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:
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-scriptsFor a pnpm project, point --consumer at the workspace root:
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-scriptsRepeat --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:
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.
.npmrcsetssave-exact=trueandmin-release-age=2to avoid same-day dependency releases during npm resolution.package-lock.jsonis the dependency ground truth. Pre-commit blocks accidental lockfile commits unlessPI_ALLOW_LOCKFILE_CHANGE=1is set.npm run checkverifies 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 --selfuse--ignore-scriptswhere supported. - CI installs with
npm ci --ignore-scripts, and a scheduled GitHub workflow runsnpm audit --omit=devplusnpm 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
npm install @earendil-works/pi-agent-coreQuick Start
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
AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM
(optional) (required)- transformContext: Prune old messages, inject external context
- 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"):
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:
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_endTool execution mode is configurable:
parallel(default): preflight tool calls sequentially, execute allowed tools concurrently, emittool_execution_endas soon as each tool is finalized, then emit toolResult messages andturn_end.toolResultsin assistant source ordersequential: 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:
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:
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:
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:
selected input events
→ prepareRequest
→ provider response
→ tool results
→ finishTurn
→ turn_end
→ existing continuation scheduling or agent_endcontinue() 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.
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:
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
/* Detailed source-code truncated for AI context efficiency. */Agent State
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):
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
// 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
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
agent.sessionId = "session-123";
agent.thinkingBudgets = {
minimal: 128,
low: 512,
medium: 1024,
high: 2048,
};Control
agent.abort(); // Cancel current operation
await agent.waitForIdle(); // Wait for completionEvents
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.
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:
- All tool calls from the current assistant message have already finished
- Steering messages are injected
- 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:
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:
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:
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.
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:
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:
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
- Installation
- Quick Start
- Providers and Models
- Auth
- Tools
- Image Input
- Image Generation
- Classification
- Thinking/Reasoning
- Stop Reasons
- Telemetry
- Error Handling
- Custom Providers
- Faux Provider for Tests
- Cross-Provider Handoffs
- System Messages
- Context Serialization
- Browser Usage
- Bundling and Tree Shaking
- OAuth Providers
- Migrating from the Old Global API
- Development
- License
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
- 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/sgpregions) - Any OpenAI-compatible API: Ollama, vLLM, LM Studio, etc.
Installation
npm install @earendil-works/pi-aiTypeBox 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).
/* 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:
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):
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
const models = builtinModels(); // a Models collection with every built-in provider registeredThis 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:
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:
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():
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:
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:
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:
// 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:
// 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:
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:
const response = await models.completeSimple(model, context, {
headers: { "X-Client": "my-app" },
transformHeaders: async (headers) => ({
...headers,
"X-Request-ID": crypto.randomUUID(),
}),
});The ordering is:
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:
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) sendspi/<version> (; <runtime>; <arch>),"short"sendspi, and{ custom: "..." }sends the given value. On Bedrock the value is appended to the AWS SDK user agent.version: the version in the detailedUser-Agent. Default: the pi-ai version.attribution: provider attribution headers. Absent (default) ornullsends none;nullalso 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>, andX-OpenRouter-Categories(comma-separated). - NVIDIA NIM:
X-BILLING-INVOKE-ORIGIN: <name>with its first letter upper-cased. - OpenCode:
x-opencode-client: <name>.
PI_ATTRIBUTIONis pi's:{ name: "pi", url: "https://pi.dev", categories: ["cli-agent"] }.- OpenRouter:
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:
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:
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 |
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 andQWEN_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
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.
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:
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:
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:
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_deltaevents,argumentscontains 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,
argumentswill be an empty object{}, neverundefined - The Google provider does not support function call streaming. Instead, you will receive a single
toolcall_deltaevent 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:
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.
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
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:
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:
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:
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:
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
ModelsandProvider; list them withgetModelsOfType('image')and run them withgenerateImages(), never the chat/stream APIs. - Image-generation models do not participate in tool calling.
- Outputs are returned in
AssistantImages.outputand can include both base64-encodedImageContentblocks andTextContentblocks. - 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, andonResponse, and results may includestopReason,responseId, andusage. - 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 |
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 asassets,{ "": { 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 theTypeSafe-Danger-Zone-Images: trueheader, which the API sends with requests that carry images. - Clef on Workers AI takes up to four PNG, JPEG, or WebP images as an
imageslist 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 withassetsbut 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.
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:
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)
// 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:
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:
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:
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, andreasoningonly whenoptions.reasoningis a string. Provider-native reasoning objects are ignored. - End attributes:
stop,usage.input,usage.output,usage.cache_read,usage.cache_write,cost.total, and optionalresponse.id.stop: "error"sets error status with the fixed categoryprovider_errorandAssistantMessage.errorMessagetruncated to 500 characters (empty when absent). Cancellation keeps statusokwithstop: "aborted". pi.ai.first_chunkmarks the first normalized event, including setup errors. Each completed text, thinking, or tool-call block emitspi.ai.blockwithtype,index, andchars; only tool calls includetool. 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:
// 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':
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:
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.
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.
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:
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):
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:
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:
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.
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.
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():
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.
/* 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
storefield - 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:
/* 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 andfaux.appendResponses([...])to add more responses. faux.modelsexposes all faux models.faux.getModel()returns the first one, andfaux.getModel(id)returns a specific one.- Use
fauxAssistantMessage(...)for scripted assistant replies. UsefauxText(...),fauxThinking(...), andfauxToolCall(...)to build content blocks without filling in low-level fields manually. - Usage is estimated at roughly 1 token per 4 characters. When
sessionIdis present andcacheRetentionis not"none", prompt cache reads and writes are simulated automatically. - Tool call arguments stream incrementally via
toolcall_deltachunks. - By default, each streamed chunk is emitted on its own microtask. Set
tokensPerSecondto 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
providerids.
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
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:
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:
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:
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:
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:
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/modelsexports the model runtime (createModels,createProvider, model helpers, and their types) without TypeBox, built-in catalogs, or SDK implementations. Other types can still useimport typefrom the root.@earendil-works/pi-aiis 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/allimports 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/allincludes 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:
esbuild app.js --bundle --platform=node --format=esm \
--banner:js='import { createRequire } from "module";const require = createRequire(import.meta.url);' \
--outfile=app.bundle.jsThis is only for Node bundles; it is not a browser or Cloudflare Workers workaround.
Bedrock is Node-only. Add it like any other provider:
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:
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.
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.
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_KEYor passapiKeyin the call options. - Local development (ADC): Run
gcloud auth application-default login - CI/Production (ADC): Set
GOOGLE_APPLICATION_CREDENTIALSto 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.
# 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:
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 providersCredentials 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:
// 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 exampleBedrockOptions) - Message conversion functions to transform the
TranscriptContextmessages to provider format; read the prompt and tools from the transcript withgetInitialSystemMessage(),getCurrentTools(), andresolveTranscript() - 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 toImageModel, and models.devtype: "decision"entries toClassifierModel; hydration groups the ignoredsrc/providers/data/.jsonvalues by API while stablesrc/providers/.models.tswrappers 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:
envApiKeyAuthfor standard key providers, a customApiKeyAuthfor ambient auth (AWS profiles, ADC),lazyOAuthwhere 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.tsand add the package subpath export inpackage.json
5. Tests (`test/`)
Create or update test files to cover the new provider:
stream.test.ts- Basic streaming and tool usetokens.test.ts- Token usage reportingabort.test.ts- Request cancellationempty.test.ts- Empty message handlingcontext-overflow.test.ts- Context limit errorsimage-limits.test.ts- Image support (if applicable)unicode-surrogate.test.ts- Unicode handlingtool-call-without-result.test.ts- Orphaned tool callsimage-tool-result.test.ts- Images in tool resultstotal-tokens.test.ts- Token counting accuracycross-provider-handoff.test.ts- Cross-provider context replayproviders.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]:
### 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
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.
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
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:
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), andreplace(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:
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
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 anErrorcarrying the same message. Tool names are also exposed as identifiers: characters that are not valid in identifiers become_(toCodemodeIdentifier), somy-toolistools.my_toolas well astools["my-tool"].ALL_TOOLSlists{ name, description }for every tool, withnameas the identifier.text(value)appends a text item toresult.output; values other than strings are JSON-stringified.console.log/info/warn/error/debugappend text items withconsole: true.image(urlOrItem)appends an image item. It accepts a base64data:URL,{ image_url }, or an image block ({ type: "image", data, mimeType }, as MCP tools and@earendil-works/pi-aiproduce). Remote URLs are rejected.exit()ends the script successfully right away, keeping its output and store writes.globalspassed to the sandbox are called as top-level functions, for example a host helperimage(ref). They behave like tools but are not recorded inresult.calls. A name likemodels.classifyputs the function on a frozenmodelsobject. Withspread: true,executereceives all call arguments as an array instead of the first one, andsignaturereplaces the declaration generated from the schemas.store(key, value)andload(key)read and write JSON values synchronously. See Store.- Nothing else: no timers,
fetch,process,require, modules, orWebAssembly.evalandFunctionwork 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:
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.
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:
// @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:
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:
// sandbox/src/index.ts
export { CodemodeSandboxDurableObject as CodemodeSandbox } from "@earendil-works/pi-codemode/cloudflare";
export default {
fetch: () => new Response("Not found", { status: 404 }),
};// 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.
// 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:
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 bytimeoutMs. QuickJS polls an interrupt handler while it runs, and the sandbox counts the polls. Past the budget, the execution fails withkind: "timeout".- The default
DEFAULT_INTERRUPT_BUDGETof 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_mslimit (30 seconds by default). Hitting that limit resets the object instead of failing one script.
- The default
- 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:
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.
timeoutMsandsignalend the execution in the caller right away, withkind: "timeout"orkind: "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:
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:
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:
/* 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()readsstartedAt, then thenowof the result being processed, never going backwards.performance.now()counts fromstartedAt. - Randomness:
Math.random()and the VM's other randomness come fromseed. - Timezone: UTC, whatever the host's.
- Garbage collection:
WeakRefandFinalizationRegistrydo 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:
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
RangeErrorfor 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 withName: messagelike 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:
curl -fsSL https://pi.dev/install.sh | shOn Windows:
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:
npm install --ignore-scripts @earendil-works/pi-coding-agentPi 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:
cd /path/to/project
piFor 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.
- Demo video on how to publish Pi sessions
- Published Pi development sessions:
badlogicgames/pi-monoon Hugging Face.
Development
Clone the repository, install its dependencies, and run Pi from source:
git clone https://github.com/earendil-works/pi
cd pi
npm install --ignore-scripts
./pi-test.shpi-test.sh can be called from any directory and preserves the caller's working directory.
Before submitting changes, run:
npm run check
./test.shRead 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
- Quick Start
- Concepts
- Persist and Resume
- Extensions
- Tools
- Code Mode
- System Prompt
- Per-Conversation Agent
- Settings
- Environment
- Reload
- Watching a Conversation
- Busy Conversations
- Reset and Handoff
- Compaction
- Agent Events (Experimental)
- Hooks
- More Conversations and Forks
- Abort and Subagents
- Child Tasks
- Task Graph
- Your Own State
- Usage and Cost
- Storage
- Errors
- Examples
- Design Documents
Installation
npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chordQuick Start
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.MemoryStoragekeeps 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 aSubmission. 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.submitcovers 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. DuplicaterequestIdpreserves the original submission and carrier. Internal admissions, such as compaction writes, emit events on their phase span without a submit span.pi.durable.phasecovers 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, ortask_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. Anabortedoutcome keeps statusok, including interruption by close or cancellation. - Every
onReportreport also emits api.durable.reportevent on its current span. Without a span, the NOOP span drops the event; no root report span is created. Onlytype, optional fixedreason,extension, andtask.idare recorded; no error payload is included.pi.durable.reportscounts all reports bytypeonly, including those without a span. Clock failures report without a phase context. TheonReportcallback is unchanged. - Committed entries and settled submissions emit
pi.durable.entryandpi.durable.submission. Committed running phase changes emitpi.durable.checkpoint; a nested call answered from its stored result emitspi.durable.replay. - Propagation stores optional opaque
tracecarriers 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.traceinherits 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 tasklinks. A steer stays in the running trace and its submit span links to that run's stored carrier. A reopened task's first invocation hasresumed: truein the same trace. - Metrics record
pi.durable.phase.duration(milliseconds; task, phase, outcome) andpi.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. AConversationhandle holds no state; compare handles byid. - 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.generationcalls the model and owns thepi.tooltasks 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:
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 donePersist and Resume
Use SQLite or JSONL storage to keep conversations across restarts:
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 unfinishedWork 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:
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.idharness.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:
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:
/* 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:
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:
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:
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:
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:
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:
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, andisError, not theoutputmeant for the model. A blocked, invalid, failed, or aborted nested call returns anisErrorresult.executeTool()itself rejects onceexecute()has returned, when the caller is aborted, or when the Harness closes; cancelling itscontextstops 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 asummary(error, duration, usage) once done, and as tool events withparentToolCallIdandparentTaskId.progress: falsekeeps a nested call's running output out ofpi.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 ownreplaypolicy, 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
usageis 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 whatexecuteTool()returns, without the model'soutput, so an image or abashtail is stored once. - Nothing bounds how deeply calls nest; a tool with the default
callersmay even call itself. Restrictcallerswhere 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:
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.
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:
section("cwd", (input) => input.env?.cwd); // rendered as <cwd>\n...\n</cwd>; undefined omits itA 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:
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, cwdExtensions 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:
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:
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:
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:
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:
registry.install(await loadCodingExtension()); // same name "coding": replaces the installed oneregistry.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:
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:
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:
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
kindanddatachanges 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 withmodel,head, oreditswaits for the next boundary like a steer. await submission.abort(context)withdraws a queued submission.- The settings
steeringMode: "all"andfollowUpMode: "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:
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:
await root.reset(undefined, context); // start from nothing
await root.reset("We were fixing the flaky login test. Continue.", context); // start from a handoff noteWhile 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.
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:
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:
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:
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), andafterTools(runs once a round's tools are done). - Tools:
beforeTool(block or rewrite arguments) andafterTool(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
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):
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'sdetailsand prints the child's events indented under the call.23-subagent-background.ts: persistent subagents behind onesubagenttool 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:
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
allSettledit resumes once every task inonis done; withfailFastthe first failed child also aborts the others.onmay name other tasks too, withallSettled. - Finishing: a task that finishes while work it owns is still running is
completing: its outcome is decided, but it becomes terminal, andwaitForTask()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().
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:
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:
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_" } toSqliteStorage.open(database, options), openNodeSqliteStorage(path, options), oropenDurableObjectSqliteStorage(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:
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:
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:
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:
- 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(itscauseis that error); watches and event streams end withsession_failed; running tasks get their abort signal;onReportgets 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 samerequestIdfinds a submission that was already admitted. - An invalid read fails only that call: a storage read throws
StorageRequestErrorfor 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 fromcommit()ormintId(),StorageRequestErrorincluded, ends the Harness as in rule 1. - 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 (
beforeToolblocks 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 hostRegistryReaderwhosesnapshot()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:
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:
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
docs/spec.md: the normative specification
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 DurableExecutionEnvthat talks to the daemon through aConnection. Its results matchNodeExecutionEnvrunning on the remote machine; only error messages may differ
(docs/semantics.md).
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:
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.tsis a documentation-lift eval.eval:docsruns each case in isolatedwithout_docsandwith_docscontainers and reports lift.- Other
*.eval.tsfiles are host evals.eval:hostruns them with Vitest on this machine. They are ordinary vitest-evals suites, not paired comparisons.
Runner code lives in src/:
cli.tsorchestrates a comparisondocker.tsbuilds the two images, discovers cases, and runs one isolated armplan.tsexpands cases into(case, variant, repetition)tasksreport.tsreads Vitest JSON, pairs arms, and computes liftharness.tsis 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.
PI_PROVIDER=openai-codex PI_MODEL=gpt-5.6-sol npm run eval -w packages/evalsThat runs host evals, then the documentation comparison. Extra CLI flags after -- go to eval:docs only.
Host only:
PI_PROVIDER=openai-codex PI_MODEL=gpt-5.6-sol npm run eval:host -w packages/evalsOne host suite:
PI_PROVIDER=openai-codex PI_MODEL=gpt-5.6-sol \
npm run eval:host -w packages/evals -- evals/documentation-audit.eval.tsRun documentation comparisons
From the repository root:
npm run eval:docs -w packages/evals -- \
--provider openai-codex \
--model gpt-5.6-solPI_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:
npm run eval:docs -w packages/evals -- \
evals/extensions.docs.eval.ts \
--runs-per-variant 5PI_EVAL_RUNS_PER_VARIANT=5 is equivalent. Vitest filters are applied during discovery:
npm run eval:docs -w packages/evals -- -t "adds the model"The runner:
- Mounts the repository ephemerally for a Docker build, packs the current workspace packages using the repository's consumer-install machinery, then creates separate
without_docsandwith_docsimages from the staged runtime. - Discovers the selected cases in both images and requires identical cohorts.
- Plans every
(case, variant, model, runNumber)arm before execution. - Runs each arm in a fresh container. A failed or missing arm is recorded and the planned cohort continues.
- Reads native Vitest JSON through
@vitest-evals/core/nodewhen a report exists. - 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.jsonandreport.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:
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
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:
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:
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 negotiate2025-06-18,2025-03-26, or2024-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
pingandroots/listrequests - 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/TelemetrySpancontract; - a shared
NOOP_TELEMETRY_CONTEXT; - opaque cross-process propagation and span links;
- counters, up-down counters, histograms, and typed metric schemas;
- a reference
InMemoryTelemetryContextimplementation; - 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
- Telemetry Concepts
- Core Context API
- Adapter Contract
- Propagation and Span Links
- Metrics
- No-op Context
- In-Memory Reference Adapter
- Stored Telemetry
- Adapter Conformance
- Typed Schemas
- Schema Metadata
- Pi Package Integration
- Security and Portability
- API Reference
- Development
- License
Installation
npm install @earendil-works/pi-telemetryTelemetry 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:
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 outcomeA 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.
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:
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:
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
okand 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 andundefinedignored; - 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.
Propagation and Span Links
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:
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:
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:
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:
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:
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:
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_METRICSandNOOP_TELEMETRY_PROPAGATIONon 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:
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'sname, inclusivestartMin/startMax,errors: true, and scalarattributes. All requested attributes must match one span, anywhere in the trace. Pages default to 20 traces; pass the returned opaquecursorwith the same filters to continue. Equal start times are ordered by trace ID.getTrace(traceId)returns a trace orundefined.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:
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:
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.
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:
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:
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:
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, andboolean;string[],number[], andboolean[].
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 expectedloworhighcardinality.
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-telemetryowns the vendor-neutral contract, no-op and in-memory reference contexts, schema utilities, and adapter conformance suite;@earendil-works/pi-aiaccepts a parentTelemetrySpanthroughtelemetryin provider request options and exportsAI_TELEMETRY_SCHEMAfrom its root and./modelsentry points;@earendil-works/pi-durableexportsDURABLE_TELEMETRY_SCHEMAfor short task-handler and submission spans, events, and metrics;@earendil-works/pi-agent-coreretains its existing agent-loop and session instrumentation and schemas.
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:
npm test
npm run buildRepository-wide type checking, formatting, linting, and smoke checks run with:
npm run checkLicense
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
TUIinterface 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
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:
TuiMainScreenrenders into the main terminal buffer and preserves terminal scrollback.TuiAltScreenrenders 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.
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:
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:
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.
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.
/* 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:
minWidthis applied as a floor after width calculation- For position: absolute
row/col> percentagerow/col>anchor marginclamps final position to stay within terminal boundsvisiblecallback controls whether overlay renders (called each frame)
Component Interface
All components implement:
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.
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:
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:
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:
- Sets
focused = trueon the component - Scans rendered output for
CURSOR_MARKER(a zero-width APC escape sequence) - Positions the hardware terminal cursor at that location
- Shows the hardware cursor only when
showHardwareCursoris 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:
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.
const container = new Container();
container.addChild(component);
container.removeChild(component);Box
Container that applies padding and background color to all children.
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 dynamicallyText
Displays multi-line text with word wrapping and padding.
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.
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.
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- SubmitCtrl+A/Ctrl+E- Line start/endCtrl+WorAlt+Backspace- Delete word backwardsCtrl+U- Delete to start of lineCtrl+K- Delete to end of lineCtrl+Left/Ctrl+Right- Word navigationAlt+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.
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 paddingFeatures:
- 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- SubmitShift+Enter,Ctrl+Enter, orAlt+Enter- New line (terminal-dependent, Alt+Enter most reliable)Tab- AutocompleteCtrl+K- Delete to end of lineCtrl+U- Delete to start of lineCtrl+WorAlt+Backspace- Delete word backwardsAlt+DorAlt+Delete- Delete word forwardsCtrl+A/Ctrl+E- Line start/endCtrl+]- 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.
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.
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.
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 Escapeaborted: boolean- Whether the loader was abortedonAbort?: () => void- Callback when user presses Escape
SelectList
Interactive selection list with keyboard navigation.
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 itemsControls:
- 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.
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.
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.
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.
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
Tabfor 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):
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:
- First Render: Output all lines without clearing scrollback
- Width Changed or Change Above Viewport: Clear screen and fully re-render
- 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:
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- Usesprocess.stdin/stdoutVirtualTerminal- For testing (uses@xterm/headless)
Utilities
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:
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:
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 widthtruncateToWidth()preserves ANSI codes and properly closes them when truncating
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 resetCaching
For performance, components should cache their rendered output and only re-render when necessary:
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:
node test/chat-simple.tsDevelopment
# Install dependencies (from monorepo root)
npm install
# Run type checking
npm run check
# Run the demo
node test/chat-simple.tsDebug logging
Set PI_TUI_WRITE_LOG to capture the raw ANSI stream written to stdout.
PI_TUI_WRITE_LOG=/tmp/tui-ansi.log node test/chat-simple.tsOverview (`scripts/issues/README.md`)
Issue triage
A local tool for the daily issue review. It runs in this order:
- Sync. Keeps a local copy of the repository's issues in
data/corpus.jsonwithgh api. The first run fetches the last 120 days; after that it syncs every 20 minutes and only fetches what changed. - 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
- searches the local copy for duplicates and related issues (
- 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 issuestriage.sqlite: pi-durable storage with one conversation per triaged issue, the triage results, and your decisions in thetriage.statedocumenttoken: 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 frontmatterowneris 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
./scripts/issues/run.sh # http://127.0.0.1:7788Open 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 issues0,1,n: set p0, p1, noneo: 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:
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.
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:
let replica = tracker.value; // shared with authority; never mutated
// For each adopted batch, in order:
replica = applyImmutable(replica, prepared.ops); // replica must equal prepared.baseWhen 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:
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:
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()orabort(). - 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 makeprepared.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, andcopyWithin. 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 = undefineddeleteskey.arr[i] = undefined,push(undefined),unshift,splice, andfillwithundefinedthrow.undefinednested anywhere inside a placed object or array throws. It is not dropped likeJSON.stringifydoes.
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() orPrepared.abort() after prepare() prevents adoption. The candidate stays
readable.
No-op batches:
- Empty
opsmeansprepared.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.revisionand 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, orprototype 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
replicatedStatesubscribers 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/deploysCodemodeSandboxDurableObjectas its own Worker,pi-codemode-sandbox.
Scripts run there, in a separate isolate.agent/has aSessionDurable Object that owns the tools. It runs scripts throughRemoteCodemodeSandbox, which keeps the tools and results inSessionwhile 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:
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:
(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
# 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.
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):
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:
// 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
cd packages/coding-agent
node examples/sdk/01-minimal.tsQuick Reference
/* 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
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.
npm run test:mcp-conformanceCI 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
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'schecks.jsonand client output.--update-baseline: write the results of a full run tobaseline.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-clientfails 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.tspath 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:
npm --prefix packages/tui run build:native:darwinThe 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:
CC=/path/to/osxcross/clang SDKROOT=/path/to/MacOSX.sdk \
npm --prefix packages/tui run build:native:darwinThe 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:
npm --prefix packages/tui run build:native:linuxTesting
Install the build dependencies plus pkg-config, Xvfb, and xclip, then run from packages/tui:
node --test test/native-clipboard-linux.test.tsTests 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:
npm --prefix packages/tui run build:native:win32For cross-builds or custom toolchains, provide MinGW-compatible compilers:
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:win32Testing clipboard writes
On a Windows test desktop, run from packages/tui in PowerShell:
$env:PI_TEST_NATIVE_CLIPBOARD = "1"
node --test test/native-platform.test.tsThis 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
pi --extension ./examples/extensions/doom-overlayThen run:
/doom-overlayThe 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 widthmaxHeight: "80%"- Maximum 80% of terminal heightanchor: "center"- Centered in terminal
Height is calculated from width to maintain DOOM's 3.2:1 aspect ratio (accounting for half-block rendering).
Credits
- id Software for the original DOOM
- doomgeneric for the portable DOOM implementation
- pi-doom for the original pi integration
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 progressCtrl+Alt+P- Toggle plan mode (shortcut)
Usage
- Enable plan mode with
/planor--planflag - Ask the agent to analyze code and create a plan
- The agent should output a numbered plan under a
Plan:header:
Plan:
1. First step description
2. Second step description
3. Third step description- Choose "Execute the plan" when prompted
- During execution, the agent marks steps complete with
[DONE:n]tags - 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
piprocess - 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
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 -> workerInstallation
From the repository root, symlink the files:
# 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")
doneSecurity 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
Use scout to find all authentication codeParallel execution
Run 2 scouts in parallel: one to find models, one to find providersChained workflow
Use a chain: first have scout find the read tool, then have planner suggest improvementsWorkflow prompts
/implement add Redis caching to the session store
/scout-and-plan refactor auth to support OAuth
/implement-and-review add input validation to API endpointsTool 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):
$ commandfor bashread ~/path:1-10for readgrep /pattern/ in ~/pathfor grep- etc.
Agent Definitions
Agents are markdown files with YAML frontmatter:
---
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 withagentScope: "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:
- Use Pi interactively to add files, run commands, direct ongoing work, and export results.
- Choose a model or connect a subscription, API key, local model, or compatible endpoint.
- Continue or branch a session to resume work or explore another approach without losing history.
- Configure Pi for your preferences, working folders, instructions, and reusable resources.
- Understand how Pi works, including tools, context, sessions, and the agent loop.
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
- Use print mode for one-off and scripted tasks.
- Use JSON event stream mode to consume structured events from one run.
- Use RPC mode to control a separate Pi process.
- Use the TypeScript SDK to run Pi inside an application.
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:
curl -fsSL https://pi.dev/install.sh | shThe 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:
npm install --ignore-scripts @earendil-works/pi-coding-agentPi 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:
nix profile add github:earendil-works/pi/stableOlder 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:
pi --version2. Start Pi
Change to the folder you want Pi to work with, then start it:
cd /path/to/folder
piThe 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:
/loginChoose 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:
Summarize @meeting-notes.md and save the action items to action-items.md.Explain how this repository is structured and how to run its checks.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:
pi --continueUse /resume to choose another saved session. See Continue or branch a session for session naming, branching, compaction, export, and sharing.
Next steps
- Use Pi interactively to learn input, commands, shortcuts, and queued messages.
- Add instructions that Pi should follow whenever it works in a folder.
- Choose a model and provider.
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:
npm uninstall -g @earendil-works/pi-coding-agentIf you used the installer, run it again and choose Uninstall Pi:
curl -fsSL https://pi.dev/install.sh | shIf you installed Pi with Nix, run:
nix profile remove piNone 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
- Contracts
- Serving objects
- Declarations
- Activation
- Principals and variants
- References
- State
- Calls, ctx and roots
- Lifetime
- Registries
- Plugins
- Hot reload
- Hosts and links
- Durable
- Errors
- Rules of thumb
Quick start
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.
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 namedserver.host: hostFamily("session:", "session"): an object lives onsession:+ its input'ssessionfield, 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.
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 withcontract_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" |
// 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
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
variantfor 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 firstgetand 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.
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.
// 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:
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 starthost.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.valueandindex.someState.valuethrownot_ready, and calling a reference throws. Use them in methods, listeners, tasks andinit. - 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 withunavailable/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
openSqlitethrows,Indexfails,Chatfails with that cause, and every waitinggetrejects. The nextgettries 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 directhost.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:
// 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:
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 itselfPrincipals 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.
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,
});createHosttypes every factory'sprincipal,ctx.principal, routes,acceptandinvalidatePrincipalpatterns, so code narrows onkindwithout casts. Facet modules say it too:defineFacet((api) => …)(an app usually exports its owndefineFacetandCtxaliases). Without a type
argument, principals areunknown.- 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 asselfis 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 })(orupstream: { 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:
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);
},
};
});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 becomegone. A newgetruns 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() |
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
getresolves 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, …), withaborted(actx.getwhose ctx is aborted or whose call settles first;host.get'ssignal), or withunavailable/ready_timeoutafter the host'sreadyTimeout. 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(aholdthat is still loading, a member read inside a factory). A member outside the current variant throwsnot_in_variant; an ended reference throws its end (released, or thegoneerror). A listener added before the hydration gets its snapshot then. state: falsegives 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
loadingreference wait until it's live, respectingctxcancellation and a deadline. - A member missing from the current variant rejects with
not_in_variant. - After
release(), every member rejects withreleased. gonemeans the object was forced away while referenced: revocation, archive, its host dying. Callgetagain, or show "unavailable".onStatusreports every status change (live,loadingwhile a link reconnects,released,gonewith 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. WithcreateHost({ linger: { types: { [typeName]: ms }, cap } }), a binding whose last reference is released stays held (and hydrated) forms, at mostcap(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
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
changeis atomic: one revision, or nothing if the callback throws. changeonly touches the paths you mutate, so fields your code doesn't know about survive. Usereplaceonly 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
resetsnapshot, and nothing is replayed. replacesends 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:
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 actx 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.
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:
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
taskor 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.
// 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
closemay still use the objects its object references. That holds for host shutdown and revocation too:host.close()andinvalidatePrincipaldispose referrers before what they reference, and close links last. Bound remote calls inclosewith a signal: a call over a link that is already down waits for a reconnect. - Forced disposal (revocation, archive) makes references
goneat once and aborts in-flight calls. - References must not form cycles. Inside one host this is checked at
get.
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 shutdownlifetime: "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
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:
plugins/chat/
contract.ts Chat, Conversation, Drafts
server.ts facet for the server
worker.ts facet for session workers
web.tsx facet for browsers// 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);
});// 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:
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
await host.reload([new URL("../plugins/chat/worker.ts", import.meta.url)]); // or reload every changed pluginWhat happens
- Load the new modules.
- 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.
- Gate: only if a cell's version was raised or an exclusive resource changes.
- Commit: in one synchronous step, install the new code, swap registry tables, restart tasks, rebind listeners, and send
resetand shape updates to subscribers. - 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 (theirctx.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
// 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:
- Roots already inside finish. Their re-entrant calls are let in.
- Old roots that haven't touched it yet get
upgrade_conflictinstead of old code over migrated state. They retry as a new root. migrateruns on a private copy, and the result is validated against the new schema.- 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
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,depsorexclusivechange:opendoesn'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 latercloseis the new one. - A changed resource opens next to the old one during prepare. The old one is closed with its own
closeonce the
old code retires. - An
exclusiveresource 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.
Hosts and links
// 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 agetdoes 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, andonProtocolMismatch(aLinkOptionsfield ofconnectandaccept) tells the
side that has to load new code. A browser reloads its page instead of reconnecting:tshost.connect(webSocketLink(socket), { onProtocolMismatch: () => location.reload() });Browsers don't take part in commits. They learn about new code through shape and
resetmessages, and an incompatible contract makes their calls fail withupgrade_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.
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
deltamodule (tracker, applyImmutable, encoder/decoder, diff) and Node/browser built-ins plustypeboxfor 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.
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 stillloadingmust know whetherh.xis 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) orhostFamily(prefix, key)(the hostprefix+ the input's required string fieldkey). Data only, in the description (and so the fingerprint and theholdtoken); there is noatoption. Agetnormalizes 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 anacceptsadapter 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.
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:
- run the factory synchronously (declarations);
- await declared resources'
open()(e.g. connect a DB); - await declared references becoming live (dependencies activate first, coalesced);
- run the
initof every cell declared with one, in parallel, each in a root of its own acting as the host principal; validate and install the values; - attach derivations, start listeners and tasks;
- object is live: references
live,readyresolves.
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 atservefor remote types, for an input that does not accept{}, and together withlinger;
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. Everyget
reaches it, whatever its principal (the factory sees the host principal); agetwith another input isinvalid/input. Other references do not change its lifetime: the host keeps its own until it closes.- Only
"host"objects may register (invalid/not_host_lifetimeotherwise). - 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.awaitHostObjectsdefaults 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 asunavailable). Host object failures also
reachonError. Installation is not a rollback transaction; setup/registration errors frominstallthrow
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 directserveand 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 awaithost.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; defaultunknown). 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 };variantmust be a declared variant; members must match it. Local factories return any object.variantis reserved as a member name. - Reference:
status(loading | live | released | gone),readypromise,variant,shape({ variant, members, contract }),onStatus(listener)(every status change, with the error forgone), late-bound members,release().self.getis synchronous (the object goes live after its references are ready);ctx.getandhost.getresolve 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), orunavailable/ready_timeout, releasing only that reference.host.holdis 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 typedT; reading before the first hydration throwsnot_ready, outside the variantnot_in_variant, after the end the end's error. A remote reference type is a union discriminated byvariant. Shape changes (reload, variant) are pushed; members missing from the current variant rejectnot_in_variant; released references rejectreleased; forced disposal makes referencesgone. - 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 forlinger.types[typeName]ms (createHost({ linger: { types, cap } }), at mostcap, 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'sclosecan still use its object's references;host.closecloses links after the objects. - Forced disposal: references
goneimmediately, 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
currentandpreviouscode versions (two-version rule). A call for a code version the host doesn't have (older than previous, or host started later) is rejectedsuperseded(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
Manifestobject; incompatible contract fingerprint ->upgrade_required.
7. Reload
Explicit trigger (reload_plugins tool, button, API). Serialized; no nesting.
- Build/type-check gate (application tooling, outside Chord): dependency-closed set; refuse breaking remote contract changes without a contract version bump.
- Load: on every affected host, import new facet modules (cache-busted).
- 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/depschanged 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. - 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
migrateinto private copies; validate.
- Admission cut: from now, an old-code-version root that has not touched the object gets
- 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 carrieschildren: each participating child's prepared report (its vote). - Retire: when no roots of
previousremain (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 mostretireDeadline; then it aborts the roots still running onprevious(their ctx signals), gives them up to 1 s to stop, and retires anyway. A coordinator retires before it sendsprepare, so itsretireframes 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
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.
9. Hosts and links
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 (serverws, browserWebSocket). - 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:holdreturnsshapeorgone; there is no separateget),shape,call/result/cancel,sub/unsub/resyncandstate,gone,root_end,retire,invalidate,ping/pong,ack, reload control (prepare/vote/commit/abort). - State: a
holdimplies the state of its variant (state: false: none untilsub;unsubstops it). The provider answers with units: ashapewhosestatelists the members the consumer lacks, followed by onestateframeas: "snap"per listed member; the consumer applies the shape once those snapshots are here. Laterstateframes of a member (addressed by hold id and memberm) areops(per-stream delta encoder),reset, orerror; asnap/resetwithoutvaluecarriesundefined. 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.againmarks a re-hold after a link loss (the consumer has values).originnames 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:
hellocarriesprotocol(PROTOCOL_VERSION, now 4; absent means 1). A side that sees another version processes nothing more, callsLinkOptions.onProtocolMismatch({ local, peer })and closes the link with reasonprotocol-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: theshapeframe names the variantvariant, code versions travel ascodeVersion(2); remote contract tokens carry their placement (host), and aholdcarries the input normalized by the consumer (3); a hold implies its state, units,origin, andshapewithout a code version (4). - Flow control: both ends announce
acksin their hello and acknowledge what they processed (ack, a cumulative count of every frame butack). 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 orunsubdrops 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 sendsresyncdownstream and drops that member's ops until the reset arrives). The first unit of a hold that is notagainand every frame but state are sent at once. Links that report no frame sizes (IPC) are never windowed; a peer withoutacksis neither windowed nor acknowledged. An acceptor can limit the calls a peer has in progress (maxCalls); calls beyond it are answered at once withunavailable/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 (
retireframe) 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 getssuperseded(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
prepareto 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
keepAliveorfatalpath, 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
selectidentity. A listener's subscription is kept across factory runs when its origin andselectare unchanged;selectcounts as unchanged when identical or with the same source text, so aselectwhose 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. Aclosethat 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
reasonfield carries the detail, for exampleinvalid/args,invalid/cycle,invalid/duplicate,invalid/read_only,invalid/not_host_lifetime,invalid/registry_conflict, orunavailable/unknown_type. AChordErrorthrown by a factory or a dependency keeps its code through every dependent; other errors becomeunavailable, with the original ascause. - Reload errors. There is no
reload_abortedcode. A failed reload throws the cause's code and reason with the messagereload aborted: …(the cause is attached). Reasons of its own areaborted/gate_deadline(with the blocking roots indetails),aborted/deadline,aborted/vote_deadlineandunavailable/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, reasongate); nothing is paused, so nothing can deadlock. Gated objects getminCodeVersion = E': calls and writes carrying an older code version are rejectedupgrade_conflictpermanently, 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_endon 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 issuperseded. - 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
acceptsadapter 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 reorderinganyOfbranches 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 ashost. 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 matchingroute;authorize(principal, hostName)runs beforeresolveand 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
changereplaces the value and is always validated;replacetransfers 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, elser.subscribe(fn)delivers values, starting with the current one;events(fn)deliverssnap, thenops, orreset. Reference state members are stable readables that thrownot_readywhile loading andnot_in_variantoutside 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.getof 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 hostinvalid/foreign_ctx. Calling a reference inside a factory throwsnot_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
pingIntervaland closes afterpingTimeoutwithout 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 bychildControl().lock()before storage opens. The manager kills the child's process group on every exit path and derivesbootingandidleForfrom the child's liveness reports. WebSocket support is structural, so Chord does not depend onws. - 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
getnever 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.
browser host server host
ref + subscribed rows -- link --> Todos view (owner or guest)
|
ref
v
TodoStore: rows + methodsWe 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.
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, andmethod(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.
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 firstget 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.
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 throughctx.get is released when this root ends. We never receive the factory's
returned object directly. A standalone call without ctx, such astodos.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 namerows 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.
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 nowtodoFactory 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.
/** 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.
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 insidechange; 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.
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 runsopen 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.
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.
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 whydatabase.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 staysreleased, 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
readresource.valuethere, including during reload. Activation happens
afterward. Bump a resource'sversionwhen 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.
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 andHost 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.
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 withnot_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. Usehost.invalidatePrincipal(match)to revoke matching objects; held references
becomegone. A UI watchesonStatusand 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 sayshost: 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 })).
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 areset, 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
memoryLinkPairwithwebSocketLinkfor a WebSocket oripcLinkfor a child-process IPC channel; object contracts stay the same.
Those adapters wrap an existing connection, not an authenticator. Configureacceptwith an authenticated principal. A browser iskind: "client";
a trusted child host is accepted withkind: "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.
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'sctx; 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 withsubscribe; 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 obeysctx.signal. Never retain a completed method'sctxfor 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.
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 awaithost.ready(). Waiting for
upstream-dependent host objects before admission would deadlock.
For real plugin files, default-export defineFacet(...). Node hosts configurecreateHost({ 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.
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.
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.
// 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.
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 totodoFactoryand replaces onlyadd; 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. Usectx.get for the first lifetime and host.get for the second.
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
changefor field edits andreplacefor 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.getfor temporary method dependencies: their references end with the call. - Pass
ctxinto 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
getbefore reading state: a reference you hold is live and hydrated, a
loading one (host.hold) throwsnot_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 andonTestFinished come from the test runner; readFileSync is used by the
tutorial's source-matching check, not by the application.
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:
node ../../node_modules/vitest/dist/cli.js --run test/tutorial.test.tsThere 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 withsrc, 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 |
| 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 to stdout
Print mode runs the supplied prompts, writes the final assistant text to stdout, and exits:
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:
pi --mode json "Review this repository" > events.jsonlThis 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:
pi --mode rpc --no-sessionCommands 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:
{
"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
- RPC client: typed Node.js integration
- RPC extension UI: custom terminal client with extension dialogs
- Command Line: startup options and mode selection
- JSON Event Stream: JSON event reference
- RPC Protocol: RPC lifecycle, framing, errors, and shutdown
- RPC Commands: command and response reference
- RPC Extension UI: extension interaction subprotocol
- SDK examples: in-process TypeScript integrations
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.
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
pi
pi --print "Summarize this repository"
git diff | pi --print "Review this change"
pi --mode json "Inspect this repository" > events.jsonlWith 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
pi --model sonnet:highSee Choose a Model for model selection and Providers for credentials.
--provider <name>
Restricts--modellookup to one provider. It requires--model.--model
Selects by exact ID or fuzzy ID/name match. It acceptsprovider/idand 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--modelor--models. It does not choose between providers that offer the same model.--thinking <level>
Setsoff,minimal,low,medium,high,xhigh, ormax. It overrides a--modelsuffix 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
pi --continueSee 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