ワンクリックで
pi-coding-agent
// Build sandboxed autonomous agent workflows with @earendil-works/pi-coding-agent and @earendil-works/pi-ai
// Build sandboxed autonomous agent workflows with @earendil-works/pi-coding-agent and @earendil-works/pi-ai
| name | pi-coding-agent |
| description | Build sandboxed autonomous agent workflows with @earendil-works/pi-coding-agent and @earendil-works/pi-ai |
| license | Apache-2.0 |
| metadata | {"author":"David East","version":"2.0"} |
Comprehensive technical documentation for building sandboxed autonomous agent workflows with the @earendil-works/pi-coding-agent, @earendil-works/pi-agent-core, and @earendil-works/pi-ai SDK packages.
| Quadrant | Purpose |
|---|---|
| 🎓 Tutorial | Acquire competence — build a working headless agent from scratch |
| 🔧 How-to Guides | Apply skills — task-oriented recipes for sandboxing, steering, and extending |
| 📖 Reference | Look up API details — exhaustive, austere, factual |
| 💡 Explanation | Understand the system — architecture, design philosophy, lifecycle |
We are going to build a headless agent that sends a prompt to an LLM, streams the response token-by-token, executes tool calls, and logs every lifecycle event to a JSONL file. By the end, you will have a working autonomous agent script you can extend for any sandboxed workflow.
We will use the following:
"type": "module" in package.json)Create a new directory and initialize it.
mkdir pi-agent-sandbox && cd pi-agent-sandbox
npm init -y
npm install @earendil-works/pi-coding-agent @earendil-works/pi-ai
Add "type": "module" to your package.json. We will write our agent in index.ts.
Open index.ts. We need four things from the SDK: credential storage, a model registry, a session manager, and the session factory.
import {
AuthStorage,
createAgentSession,
ModelRegistry,
SessionManager,
} from "@earendil-works/pi-coding-agent";
import { getModel } from "@earendil-works/pi-ai";
Notice that we import getModel from pi-ai — this is the model catalog. Everything else comes from pi-coding-agent, which is the orchestration layer.
We need to set up the credential pipeline and select a model. AuthStorage manages API keys, and ModelRegistry resolves them at call time.
const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const model = getModel("google", "gemini-3-flash-preview");
Now we create the session. SessionManager.inMemory() gives us a session that lives only in process memory — no files on disk, perfect for ephemeral sandbox runs.
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
model,
});
The createAgentSession factory returns an object containing the session (an AgentSession instance). This is the primary handle for all agent operations.
Before we send a prompt, we subscribe to the event stream. This is how we observe everything the agent does.
import { appendFile } from "node:fs/promises";
import { mkdirSync, existsSync } from "node:fs";
const logsDir = "./logs";
if (!existsSync(logsDir)) mkdirSync(logsDir);
session.subscribe(async (event) => {
// Log every event to a JSONL file
await appendFile(
`${logsDir}/${session.sessionId}.jsonl`,
JSON.stringify(event) + "\n"
);
// Stream text deltas to stdout
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
Every agent event flows through this subscription — agent_start, turn_start, message_start, message_update, tool_execution_start, tool_execution_end, turn_end, agent_end. We will explore these in detail in the Explanation: Agent Lifecycle section.
await session.prompt("List the files in the current directory and describe what you see.");
console.log("\n--- Agent finished ---");
Run it:
npx tsx index.ts
You will see the LLM response streaming to your terminal. If the agent has tools enabled (it does by default — bash, read, edit, write, grep, find, ls), it will execute an ls or bash command and describe the output.
Open the JSONL file in ./logs/. Each line is a single AgentEvent. You will see the full lifecycle: the agent started, a turn began, the LLM generated a message with tool calls, the tools executed, and the agent ended.
Congratulations. You now have a functioning autonomous agent. The next sections show you how to sandbox it, steer it mid-run, and extend it with custom tools.
To confine all file operations to a specific directory, set the cwd option. All built-in tools (bash, read, edit, write, grep, find, ls) resolve paths relative to this directory.
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
model,
cwd: "/tmp/sandbox-workspace",
});
To limit which tools the agent can use, pass tools or readOnlyTools:
import { createReadOnlyTools } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
authStorage,
modelRegistry,
model,
cwd: "/tmp/sandbox-workspace",
readOnlyTools: true, // Only read, grep, find, ls — no bash, edit, write
});
For fine-grained control, provide explicit customTools:
import { createToolDefinition } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession({
// ...
customTools: [
createToolDefinition("read", "/tmp/sandbox-workspace"),
createToolDefinition("grep", "/tmp/sandbox-workspace"),
],
});
Replace SessionManager.inMemory() with a file-backed session to persist conversation state across process restarts:
const sessionManager = SessionManager.create("/path/to/sessions-dir");
const { session } = await createAgentSession({
sessionManager,
// ...
});
The session manager writes an append-only JSONL file. On restart, it replays entries to reconstruct state.
const { session } = await createAgentSession({
// ...
systemPrompt: "You are a security auditor. Only analyze code for vulnerabilities.",
appendSystemPrompt: ["Never execute destructive commands."],
});
systemPrompt replaces the default. appendSystemPrompt adds additional rules after the default system prompt.
Steering messages are injected after the current assistant turn finishes, before the next LLM call. Use this to redirect the agent while it is working.
// While the agent is streaming...
session.steer("Focus on the authentication module, not the UI.");
Follow-up messages are delivered only after the agent would otherwise stop (no more tool calls, no steering messages). Use this for chained tasks.
session.followUp("Now write unit tests for the code you just created.");
By default, all queued messages drain at once. To process them one at a time:
session.steeringMode = "one-at-a-time";
session.followUpMode = "one-at-a-time";
session.abort();
This sends an abort signal to the active run. Tool executions receive the signal and should terminate gracefully. The agent emits agent_end after abort.
await session.waitForIdle();
// Safe to read state, send new prompts, or shut down
If using createAgentSession with extensions enabled, provide an ExtensionFactory:
import { Type } from "typebox";
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
const myExtension: ExtensionFactory = (pi) => {
pi.registerTool({
name: "count_lines",
label: "Count Lines",
description: "Counts lines in a file",
parameters: Type.Object({
filePath: Type.String({ description: "Path to the file" }),
}),
async execute(toolCallId, params, signal) {
const { execSync } = await import("node:child_process");
const count = execSync(`wc -l < "${params.filePath}"`)
.toString()
.trim();
return {
content: [{ type: "text", text: `${count} lines` }],
details: { lineCount: parseInt(count) },
};
},
});
};
const auditExtension: ExtensionFactory = (pi) => {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command.includes("rm ")) {
return { block: true, reason: "Destructive commands are blocked." };
}
});
pi.on("agent_end", async (event, ctx) => {
console.log(`Agent completed. ${event.messages.length} messages produced.`);
});
};
const notifierExtension: ExtensionFactory = (pi) => {
pi.on("tool_execution_end", async (event, ctx) => {
if (event.isError) {
pi.sendMessage({
customType: "error_notification",
content: `Tool ${event.toolName} failed.`,
display: `⚠️ ${event.toolName} error`,
});
}
});
};
createAgentSession(options)Factory function. Returns { session: AgentSession }.
| Parameter | Type | Required | Description |
|---|---|---|---|
sessionManager | SessionManager | Yes | Session persistence backend. Use SessionManager.inMemory() or SessionManager.create(dir). |
authStorage | AuthStorage | Yes | Credential storage. Created via AuthStorage.create(). |
modelRegistry | ModelRegistry | Yes | Model resolution registry. Created via ModelRegistry.create(authStorage). |
model | Model<any> | Yes | Initial model. Obtained via getModel(provider, modelId) from @earendil-works/pi-ai. |
cwd | string | No | Working directory for tool execution. Defaults to process.cwd(). |
systemPrompt | string | No | Replaces the default system prompt. |
appendSystemPrompt | string[] | No | Additional system prompt sections appended after the default. |
customTools | ToolDefinition[] | No | Explicit tool set. Overrides default tools when provided. |
readOnlyTools | boolean | No | When true, registers only read, grep, find, ls. |
extensionFactories | ExtensionFactory[] | No | Extension factory functions to load at session creation. |
settingsManager | SettingsManager | No | Settings backend. Created via SettingsManager.inMemory() or SettingsManager.create(cwd). |
resourceLoader | ResourceLoader | No | Custom resource loader for skills, prompts, themes, and extensions. |
AgentSessionPrimary interface for controlling the agent. Extends Agent from @earendil-works/pi-agent-core.
| Property | Type | Description |
|---|---|---|
sessionId | string | Unique session identifier. |
state | AgentState | Current agent state (system prompt, model, tools, messages, streaming status). |
state.isStreaming | boolean | true while the agent is processing a prompt or continuation. |
state.messages | AgentMessage[] | Current conversation transcript. |
state.tools | AgentTool[] | Currently registered tools. |
state.pendingToolCalls | ReadonlySet<string> | Tool call IDs currently executing. |
state.errorMessage | string | undefined | Error from the most recent failed assistant turn. |
signal | AbortSignal | undefined | Active abort signal for the current run. |
| Method | Signature | Description |
|---|---|---|
prompt | (message: AgentMessage | AgentMessage[] | string, images?: ImageContent[]) => Promise<void> | Start a new agent turn. Accepts a string, single message, or batch. |
continue | () => Promise<void> | Continue from the current transcript. Last message must be user or tool-result. |
subscribe | (listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void) => () => void | Subscribe to lifecycle events. Returns unsubscribe function. |
steer | (message: AgentMessage) => void | Queue a message for injection after the current turn. |
followUp | (message: AgentMessage) => void | Queue a message that runs after the agent would otherwise stop. |
abort | () => void | Abort the current run. |
waitForIdle | () => Promise<void> | Resolve when the current run and all event listeners settle. |
reset | () => void | Clear transcript, runtime state, and queued messages. |
clearSteeringQueue | () => void | Remove all queued steering messages. |
clearFollowUpQueue | () => void | Remove all queued follow-up messages. |
clearAllQueues | () => void | Remove all queued messages. |
hasQueuedMessages | () => boolean | true when either queue has pending messages. |
cycleModel | () => void | Cycle to the next model in the scoped model set. |
| Property | Type | Default | Description |
|---|---|---|---|
steeringMode | "all" | "one-at-a-time" | "all" | Controls how steering messages drain. |
followUpMode | "all" | "one-at-a-time" | "all" | Controls how follow-up messages drain. |
AgentState| Field | Type | Description |
|---|---|---|
systemPrompt | string | System prompt sent with each model request. |
model | Model<any> | Active model. |
thinkingLevel | ThinkingLevel | Requested reasoning level ("off", "minimal", "low", "medium", "high", "xhigh"). |
tools | AgentTool[] | Available tools. Assigning copies the array. |
messages | AgentMessage[] | Conversation transcript. Assigning copies the array. |
isStreaming | boolean (readonly) | true during prompt processing. |
streamingMessage | AgentMessage | undefined (readonly) | Partial assistant message during streaming. |
pendingToolCalls | ReadonlySet<string> (readonly) | IDs of currently executing tool calls. |
errorMessage | string | undefined (readonly) | Error from last failed turn. |
AgentEvent (Union)Events emitted by Agent.subscribe().
| Event Type | Key Fields | Timing |
|---|---|---|
agent_start | — | Agent loop begins. |
agent_end | messages: AgentMessage[] | Agent loop ends. Last event emitted per run. |
turn_start | — | Individual LLM turn begins. |
turn_end | message: AgentMessage, toolResults: ToolResultMessage[] | LLM turn ends. |
message_start | message: AgentMessage | Message begins (user, assistant, or tool result). |
message_update | message: AgentMessage, assistantMessageEvent: AssistantMessageEvent | Token-by-token streaming delta. |
message_end | message: AgentMessage | Message finishes. |
tool_execution_start | toolCallId, toolName, args | Tool begins executing. |
tool_execution_update | toolCallId, toolName, args, partialResult | Partial/streaming tool output. |
tool_execution_end | toolCallId, toolName, result, isError | Tool finishes. |
SessionManagerManages append-only conversation history as a tree of entries.
| Method | Description |
|---|---|
SessionManager.create(sessionDir) | File-backed session manager. Writes JSONL to sessionDir. |
SessionManager.inMemory() | In-memory session manager. No file I/O. |
| Method | Signature | Description |
|---|---|---|
appendEntry | (entry) => void | Append a session entry to the current branch. |
getCurrentBranch | () => SessionEntry[] | Get the linear history of the active branch. |
getTree | () => SessionEntry[] | Get all entries across all branches. |
navigateTo | (entryId) => void | Navigate to a specific entry in the tree. |
fork | (entryId, position?) => string | Create a branch from a specific entry. Returns new session file path. |
exportToHtml | () => string | Export current branch to HTML. |
exportToJsonl | () => string | Export current branch to JSONL. |
| Type | Description |
|---|---|
UserEntry | User message. |
AssistantEntry | Assistant response. |
ToolCallEntry | Tool call from assistant. |
ToolResultEntry | Result from tool execution. |
CompactionEntry | Summary created by context compaction. |
BranchSummaryEntry | Summary created when navigating branches. |
CustomEntry | Extension-defined custom data. |
AgentSessionRuntimeOrchestrates session transitions and CWD-bound services.
| Method | Signature | Description |
|---|---|---|
switchSession | (sessionPath, options?) => Promise<{cancelled}> | Switch to a different session file. |
forkSession | (entryId, options?) => Promise<{cancelled}> | Fork from a specific entry. |
newSession | (options?) => Promise<{cancelled}> | Start a new session. |
navigateTree | (targetId, options?) => Promise<{cancelled}> | Navigate to a point in the session tree. |
shutdown | () => void | Gracefully shut down the runtime. |
ModelRegistryResolves API keys and manages provider configurations.
| Method | Description |
|---|---|
ModelRegistry.create(authStorage) | Create a registry bound to credential storage. |
getApiKey(provider) | Resolve the API key for a provider. |
getModel(provider, modelId) | Get a model instance. |
AuthStoragePersistent credential storage.
| Method | Description |
|---|---|
AuthStorage.create() | Create default credential storage (typically ~/.pi/auth). |
SettingsManagerManages global and project-level settings.
| Constructor | Description |
|---|---|
SettingsManager.create(cwd, agentDir?) | File-backed settings from .pi/settings.json. |
SettingsManager.inMemory(settings?) | In-memory settings (no file I/O). |
SettingsManager.fromStorage(storage) | Custom storage backend. |
Key settings categories: compaction, retry, transport, thinking budgets, terminal display, model filtering, shell configuration.
ExtensionFactorytype ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
A factory function that receives the ExtensionAPI and registers handlers, tools, commands, and shortcuts.
ExtensionAPIThe registration surface available inside ExtensionFactory.
pi.on(eventName, handler)
| Event Name | Event Type | Result Type | Description |
|---|---|---|---|
session_start | SessionStartEvent | void | Session started, loaded, or reloaded. reason: "startup", "reload", "new", "resume", "fork". |
session_shutdown | SessionShutdownEvent | void | Extension runtime tearing down. reason: "quit", "reload", "new", "resume", "fork". |
session_before_switch | SessionBeforeSwitchEvent | SessionBeforeSwitchResult | Before switching sessions. Return { cancel: true } to block. |
session_before_fork | SessionBeforeForkEvent | SessionBeforeForkResult | Before forking. Return { cancel: true } to block. |
session_before_compact | SessionBeforeCompactEvent | SessionBeforeCompactResult | Before context compaction. Return { cancel: true } or provide custom compaction. |
session_before_tree | SessionBeforeTreeEvent | SessionBeforeTreeResult | Before tree navigation. Return { cancel: true } to block. |
session_compact | SessionCompactEvent | void | After compaction completes. |
session_tree | SessionTreeEvent | void | After tree navigation completes. |
context | ContextEvent | ContextEventResult | Before each LLM call. Can modify messages. |
before_agent_start | BeforeAgentStartEvent | BeforeAgentStartEventResult | After prompt submitted, before agent loop. Can replace system prompt. |
before_provider_request | BeforeProviderRequestEvent | Payload | Before provider HTTP request. Can replace payload. |
after_provider_response | AfterProviderResponseEvent | void | After provider HTTP response. |
agent_start | AgentStartEvent | void | Agent loop starts. |
agent_end | AgentEndEvent | void | Agent loop ends. |
turn_start | TurnStartEvent | void | LLM turn starts. |
turn_end | TurnEndEvent | void | LLM turn ends. |
message_start | MessageStartEvent | void | Message begins. |
message_update | MessageUpdateEvent | void | Token streaming delta. |
message_end | MessageEndEvent | MessageEndEventResult | Message ends. Can replace finalized message. |
tool_execution_start | ToolExecutionStartEvent | void | Tool begins executing. |
tool_execution_update | ToolExecutionUpdateEvent | void | Partial tool output. |
tool_execution_end | ToolExecutionEndEvent | void | Tool finishes. |
tool_call | ToolCallEvent | ToolCallEventResult | Before tool executes. Can block. Input is mutable. |
tool_result | ToolResultEvent | ToolResultEventResult | After tool executes. Can modify result. |
input | InputEvent | InputEventResult | User input received. Can transform or handle. |
user_bash | UserBashEvent | UserBashEventResult | User executes ! command. Can intercept. |
model_select | ModelSelectEvent | void | Model changed. |
thinking_level_select | ThinkingLevelSelectEvent | void | Thinking level changed. |
resources_discover | ResourcesDiscoverEvent | ResourcesDiscoverResult | Discovery phase. Return additional skill/prompt/theme paths. |
pi.registerTool(toolDefinition)
pi.registerCommand(name, { description?, handler, getArgumentCompletions? })
pi.registerShortcut(keyId, { description?, handler })
pi.registerFlag(name, { description?, type, default? })
pi.sendMessage(message, options?) // Custom message
pi.sendUserMessage(content, options?) // User message (triggers turn)
pi.appendEntry(customType, data?) // Persistence-only entry
pi.registerProvider(name, config)
pi.unregisterProvider(name)
pi.getFlag(name) // Get CLI flag value
pi.getActiveTools() // Get active tool names
pi.getAllTools() // Get all tools with metadata
pi.setActiveTools(toolNames) // Set active tools
pi.getCommands() // Get slash commands
pi.setModel(model) // Set current model
pi.getThinkingLevel() // Get thinking level
pi.setThinkingLevel(level) // Set thinking level
ExtensionContextContext passed to event handlers. Provides read access to session state and control methods.
| Field / Method | Type | Description |
|---|---|---|
ui | ExtensionUIContext | UI interaction methods (select, confirm, input, notify). |
hasUI | boolean | false in headless/RPC mode. |
cwd | string | Current working directory. |
sessionManager | ReadonlySessionManager | Read-only session manager. |
modelRegistry | ModelRegistry | Model registry for key resolution. |
model | Model<any> | undefined | Current model. |
isIdle() | () => boolean | true when not streaming. |
signal | AbortSignal | undefined | Active abort signal. |
abort() | () => void | Abort current operation. |
hasPendingMessages() | () => boolean | true when messages are queued. |
shutdown() | () => void | Graceful shutdown. |
getContextUsage() | () => ContextUsage | undefined | Token usage: { tokens, contextWindow, percent }. |
compact(options?) | () => void | Trigger context compaction. |
getSystemPrompt() | () => string | Current effective system prompt. |
ToolDefinition<TParams, TDetails, TState>Schema for registering a custom LLM-callable tool.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Tool name used in LLM tool calls. |
label | string | Yes | Human-readable label. |
description | string | Yes | Description sent to the LLM. |
parameters | TSchema | Yes | TypeBox parameter schema. |
execute | (toolCallId, params, signal?, onUpdate?) => Promise<AgentToolResult> | Yes | Execution function. Throw on failure. |
promptSnippet | string | No | One-line snippet for system prompt "Available tools" section. |
promptGuidelines | string[] | No | Guideline bullets appended to system prompt. |
executionMode | "sequential" | "parallel" | No | Per-tool execution mode override. |
prepareArguments | (args) => Static<TParams> | No | Compatibility shim for raw args before validation. |
renderCall | (args, theme, context) => Component | No | Custom rendering for tool call display. |
renderResult | (result, options, theme, context) => Component | No | Custom rendering for tool result display. |
AgentToolResult<T>Return value from tool execution.
| Field | Type | Description |
|---|---|---|
content | (TextContent | ImageContent)[] | Content returned to the model. |
details | T | Structured details for logs or UI rendering. |
terminate | boolean? | Hint that the agent should stop after this tool batch. Only effective when all tools in the batch set this to true. |
| Tool Name | Factory | Description |
|---|---|---|
bash | createBashTool(cwd, options?) | Execute shell commands. |
read | createReadTool(cwd, options?) | Read file contents with line ranges. |
edit | createEditTool(cwd, options?) | Apply targeted edits to files. |
write | createWriteTool(cwd, options?) | Write or overwrite files. |
grep | createGrepTool(cwd, options?) | Search file contents with regex. |
find | createFindTool(cwd, options?) | Find files by name/pattern. |
ls | createLsTool(cwd, options?) | List directory contents. |
| Function | Returns | Description |
|---|---|---|
createCodingTools(cwd, options?) | Tool[] | All tools: bash, read, edit, write, grep, find, ls. |
createReadOnlyTools(cwd, options?) | Tool[] | Read-only: read, grep, find, ls. |
createAllTools(cwd, options?) | Record<ToolName, Tool> | All tools as a named record. |
createToolDefinition(name, cwd, options?) | ToolDef | Single tool definition by name. |
createCodingToolDefinitions(cwd, options?) | ToolDef[] | All coding tool definitions. |
createReadOnlyToolDefinitions(cwd, options?) | ToolDef[] | Read-only tool definitions. |
| Mode | Behavior |
|---|---|
"sequential" | Each tool call is prepared, executed, and finalized before the next starts. |
"parallel" | Tool calls are prepared sequentially, then allowed tools execute concurrently. tool_execution_end emits in completion order. Tool-result message artifacts emit in assistant source order. |
AgentLoopConfig Hooks| Hook | Signature | Description |
|---|---|---|
convertToLlm | (messages: AgentMessage[]) => Message[] | Required. Transforms AgentMessage[] to LLM-compatible Message[] before each call. |
transformContext | (messages, signal?) => Promise<AgentMessage[]> | Optional pre-convertToLlm transform (pruning, injection). |
getApiKey | (provider) => string | undefined | Dynamic API key resolution per call. |
shouldStopAfterTurn | (context) => boolean | Graceful stop after current turn. |
getSteeringMessages | () => Promise<AgentMessage[]> | Return steering messages for injection. |
getFollowUpMessages | () => Promise<AgentMessage[]> | Return follow-up messages for continuation. |
beforeToolCall | (context, signal?) => Promise<BeforeToolCallResult> | Gate tool execution. Return { block: true } to prevent. |
afterToolCall | (context, signal?) => Promise<AfterToolCallResult> | Modify tool results after execution. |
ResourceLoader / DefaultResourceLoaderLoads extensions, skills, prompt templates, themes, and project context files.
| Option | Type | Description |
|---|---|---|
cwd | string | Project root directory. |
agentDir | string | Agent configuration directory (e.g., .pi). |
extensionFactories | ExtensionFactory[] | Programmatic extensions. |
noExtensions | boolean | Disable extension loading. |
noSkills | boolean | Disable skill loading. |
noPromptTemplates | boolean | Disable prompt template loading. |
noThemes | boolean | Disable theme loading. |
noContextFiles | boolean | Disable context file loading. |
systemPrompt | string | Override system prompt. |
appendSystemPrompt | string[] | Append to system prompt. |
extensionsOverride | (base) => LoadExtensionsResult | Transform loaded extensions. |
skillsOverride | (base) => { skills, diagnostics } | Transform loaded skills. |
session.prompt()?Because the Pi SDK is designed for autonomous operation, understanding the lifecycle is essential for building predictable, debuggable pipelines. The lifecycle is not a simple request-response — it is a multi-turn loop with interleaving tool execution, steering injection, and follow-up continuation.
flowchart TD
A["prompt()"] --> B["agent_start"]
B --> C["turn_start"]
C --> D["LLM Call"]
D --> E["message_start → message_update* → message_end"]
E --> F{"Tool calls?"}
F -->|Yes| G["tool_execution_start → tool_execution_end"]
G --> H["turn_end"]
H --> I{"shouldStopAfterTurn?"}
I -->|Yes| J["agent_end"]
I -->|No| K{"Steering messages?"}
K -->|Yes| L["Inject steering → turn_start"]
L --> D
K -->|No| M{"Follow-up messages?"}
M -->|Yes| N["Inject follow-up → turn_start"]
N --> D
M -->|No| J
F -->|No| H
A single prompt() call can produce multiple turns. Each turn consists of one LLM call and zero or more tool executions. The agent continues looping until one of three exit conditions is met:
shouldStopAfterTurn returns true.session.abort().When the LLM response contains tool calls, they are executed according to the toolExecution mode:
beforeToolCall hooks), then executed concurrently. Results are emitted in completion order for tool_execution_end, but tool-result messages are assembled back in the original assistant source order for the next LLM context.Individual tools can override this via their executionMode field. A tool marked "sequential" in a parallel batch causes the entire batch to fall back to sequential mode.
The agent maintains two distinct message queues, and the distinction is critical for autonomous workflows:
Both queues support "all" mode (drain everything at once) and "one-at-a-time" mode (drain one message, then re-enter the turn loop).
For a typical multi-tool turn, the precise emission order is:
agent_start
turn_start
message_start (assistant)
message_update* (streaming deltas)
message_end (assistant)
tool_execution_start (tool A)
tool_execution_start (tool B) // parallel mode
tool_execution_end (whichever finishes first)
tool_execution_end (the other)
turn_end
// steering check → follow-up check
turn_start // if messages queued
...
turn_end
agent_end
agent_end contractagent_end is always the last event. However, the agent does not become idle until all awaited agent_end listeners have settled. This means a subscriber can perform async cleanup in its agent_end handler and the caller's await session.prompt() or await session.waitForIdle() will wait for it.
A naive conversation store would be a flat array of messages. The Pi SDK uses a tree for a reason: it enables branching. When a user (or an autonomous agent) realizes a conversation path was wrong, they can fork from any earlier point without losing the original history.
Each session entry has a unique ID and a parent ID. The "current conversation" is always a linear path from the root to the current leaf. Branching creates a sibling entry that starts a new path.
graph TD
A["Root"] --> B["User: Analyze this file"]
B --> C["Assistant: I see a bug in line 42"]
C --> D["User: Fix it"]
C --> E["User: Actually, focus on tests"]
D --> F["Assistant: Fixed"]
E --> G["Assistant: Writing tests..."]
In this tree, entries D/F and E/G are separate branches from the same parent C. The session manager tracks which leaf is active.
Session files are JSONL (one JSON object per line). Every mutation is an append — entries are never modified or deleted. This makes the format:
On load, the session manager replays all entries to reconstruct the tree in memory.
As conversations grow, they exceed the model's context window. The compaction system addresses this:
session_before_compact event fires, giving extensions a chance to cancel or provide custom compaction.CompactionEntry.Extensions can control compaction via settings: reserveTokens (how many tokens to keep free), keepRecentTokens (how many recent tokens to preserve verbatim).
When navigating from one branch to another, the session can optionally produce a BranchSummaryEntry — a summary of the branch being left. This preserves context without carrying the full history into the new branch.
The SDK is split into three NPM packages, each at a different abstraction level. Understanding this layering is essential for knowing which imports to use and where extension points live.
graph BT
A["@earendil-works/pi-ai<br/><i>Model catalog, message protocol, streaming</i>"] --> B["@earendil-works/pi-agent-core<br/><i>Agent loop, state, events, tool execution</i>"]
B --> C["@earendil-works/pi-coding-agent<br/><i>Session orchestration, tools, extensions, persistence</i>"]
@earendil-works/pi-ai — The foundationThis is the model-agnostic abstraction layer. It defines:
Model<Api>: A model instance with provider, ID, context window size, cost, and capabilities.Message: The union of UserMessage, AssistantMessage, and ToolResultMessage — the canonical LLM message protocol.AssistantMessageEvent: Streaming events (text_delta, thinking_delta, tool_call_start, etc.).streamSimple: The low-level function that sends a context to a provider and returns an event stream.getModel(provider, id): Model catalog lookup.This package knows nothing about agents, sessions, or tools. It is pure model interaction.
@earendil-works/pi-agent-core — The agent loopThis package adds autonomous behavior on top of pi-ai:
Agent: The stateful wrapper that owns the transcript, executes tools, and emits lifecycle events.agentLoop / agentLoopContinue: The low-level loop that drives the LLM → tools → LLM cycle.AgentTool: The tool interface with execute(), parameter schemas, and execution mode.AgentEvent: The event union (agent_start, turn_start, message_update, tool_execution_end, etc.).This package has no concept of sessions, files, or extensions. It is a pure in-memory agent runtime.
@earendil-works/pi-coding-agent — The orchestration layerThis is the package you import in application code. It composes the lower layers into a complete system:
createAgentSession: The factory that wires everything together — model registry, session manager, tools, extensions.AgentSession: Extends Agent with session management, model cycling, and runtime services.AgentSessionRuntime: Manages session transitions (switch, fork, new) and CWD-bound services.SessionManager: The append-only tree persistence layer.ExtensionRunner / ExtensionAPI: The extension system that allows dynamic tool registration, event hooks, and command definitions.ResourceLoader: Discovers and loads extensions, skills, prompts, and themes from the filesystem.SettingsManager: Global and project-level configuration.When session.prompt("Fix the bug") is called:
pi-coding-agent assembles the system prompt (from settings, skills, context files), converts the prompt string to an AgentMessage, and passes it to the Agent.pi-agent-core enters the agent loop: it calls convertToLlm to transform AgentMessage[] to Message[], then invokes streamSimple.pi-ai sends the HTTP request to the provider, streams back AssistantMessageEvent deltas.pi-agent-core assembles the deltas into an AssistantMessage. If it contains tool calls, it executes them via AgentTool.execute().pi-coding-agent provides the tool implementations (bash runs a shell command, read opens a file, etc.), fires extension events (tool_call, tool_result), and persists everything via SessionManager.This layering means you can use pi-ai alone for simple model calls, pi-agent-core for custom agent loops without the coding-agent opinion, or pi-coding-agent for the full batteries-included experience.
Enforces a disciplined Red-Green-Refactor (TDD) workflow in TypeScript with Vitest. Use when creating new features, fixing bugs, or migrating logic to ensure high-quality, verifiable implementations. Pairs with the typed-service-contract skill.
Architecture standard for building robust, type-safe TypeScript services using the Spec and Handler pattern. Use when building CLIs, libraries, APIs, or complex business logic that requires strict input validation, exhaustive error handling, and testable vertical slices.
Guides the creation, restructuring, and review of technical documentation using the Diátaxis framework. Use when writing docs, auditing existing docs for "smells", or structuring complex documentation hierarchies.