diff --git a/examples/blueprint/Dockerfile b/examples/blueprint/Dockerfile
index cc4b1fa..4a0ad53 100644
--- a/examples/blueprint/Dockerfile
+++ b/examples/blueprint/Dockerfile
@@ -1,6 +1,7 @@
FROM runloop:runloop/starter-x86_64
RUN npm install -g @zed-industries/codex-acp
+RUN npm install -g @earendil-works/pi-coding-agent@0.82.1
USER user
WORKDIR /home/user
diff --git a/examples/blueprint/README.md b/examples/blueprint/README.md
index 6a508a8..8c5fe31 100644
--- a/examples/blueprint/README.md
+++ b/examples/blueprint/README.md
@@ -2,7 +2,7 @@
> **Alpha — subject to change.** This example uses an SDK in early development. APIs and behavior may change without notice between versions.
-Builds the shared `axon-agents` Runloop [blueprint](https://docs.runloop.ai/guides/blueprints) used by examples that demonstrate pre-baked agent images. The blueprint bakes the agent binaries (Claude Code, OpenCode, Codex ACP) into a devbox image so subsequent devboxes start quickly and reproducibly.
+Builds the shared `axon-agents` Runloop [blueprint](https://docs.runloop.ai/guides/blueprints) used by examples that demonstrate pre-baked agent images. The blueprint bakes the agent binaries (Claude Code, OpenCode, Codex ACP, Codex CLI, Pi) into a devbox image so subsequent devboxes start quickly and reproducibly.
**You must run this once before any other example will work.** The other examples create devboxes with `blueprint_name: "axon-agents"` — if that blueprint does not exist on your Runloop account, devbox creation will fail.
@@ -44,6 +44,7 @@ See [`Dockerfile`](Dockerfile) for the exact contents. At the time of writing it
- [OpenCode](https://opencode.ai) — for ACP examples using OpenCode
- [Codex ACP](https://www.npmjs.com/package/@zed-industries/codex-acp) — for ACP examples using Codex
- [Codex CLI](https://developers.openai.com/codex/cli) — for native Codex module examples (pinned to 0.144.1, matching the SDK's vendored protocol types)
+- [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) — for native Pi module examples (pinned to 0.82.1, matching the wire types hand-written in `sdk/src/pi/protocol/`)
## Alternatives
diff --git a/examples/combined-app/.env.example b/examples/combined-app/.env.example
index 8a0a79c..9cf0248 100644
--- a/examples/combined-app/.env.example
+++ b/examples/combined-app/.env.example
@@ -1,3 +1,5 @@
RUNLOOP_API_KEY=your_runloop_api_key
ANTHROPIC_API_KEY=your_anthropic_api_key
OPENAI_API_KEY=your_openai_api_key
+NEBIUS_API_KEY=your_nebius_api_key
+NEBIUS_BASE_URL=your_nebius_endpoint_url
diff --git a/examples/combined-app/README.md b/examples/combined-app/README.md
index 0c379e2..3470abd 100644
--- a/examples/combined-app/README.md
+++ b/examples/combined-app/README.md
@@ -2,7 +2,7 @@
> **Alpha — subject to change.** This example uses an SDK in early development. APIs and behavior may change without notice between versions.
-A full-stack demo that supports ACP, Claude Code, and Codex agents running in Runloop devboxes. An Express backend manages agent connections (one per protocol) and fans out SDK timeline events to a React frontend over a single WebSocket. Multiple agents can run concurrently.
+A full-stack demo that supports ACP, Claude Code, Codex, and Pi agents running in Runloop devboxes. An Express backend manages agent connections (one per protocol) and fans out SDK timeline events to a React frontend over a single WebSocket. Multiple agents can run concurrently.
## Prerequisites
@@ -10,6 +10,7 @@ A full-stack demo that supports ACP, Claude Code, and Codex agents running in Ru
- A [Runloop](https://runloop.ai) API key
- An [Anthropic](https://anthropic.com) API key (required for Claude agents)
- An [OpenAI](https://platform.openai.com) API key (required for Codex agents)
+- A Nebius API key and base URL (required for Pi agents, which run GLM-5.2 on Runloop's dedicated Nebius endpoint)
- The `@runloop/remote-agents-sdk` SDK built locally (`cd ../../sdk && bun run build`)
## Setup
@@ -29,8 +30,15 @@ Add your keys to `.env`:
RUNLOOP_API_KEY=your_runloop_api_key
ANTHROPIC_API_KEY=your_anthropic_api_key
OPENAI_API_KEY=your_openai_api_key
+NEBIUS_API_KEY=your_nebius_api_key
+NEBIUS_BASE_URL=your_nebius_endpoint_url
```
+Pi agents read `NEBIUS_API_KEY` and `NEBIUS_BASE_URL` from the devbox
+environment. At launch the server writes `~/.pi/agent/models.json` declaring a
+`nebius` provider whose `apiKey` is the literal `"$NEBIUS_API_KEY"`, which Pi
+interpolates from the environment — the key itself is never written to disk.
+
Codex agents authenticate via a `~/.codex/auth.json` written into the devbox at
launch. By default it's generated in api-key mode from `OPENAI_API_KEY`. To use
ChatGPT-plan auth instead, set `CODEX_AUTH_JSON` to the full contents of your
@@ -42,7 +50,7 @@ CODEX_AUTH_JSON="$(cat ~/.codex/auth.json)"
### Build the shared blueprint (one-time, required)
-This example provisions devboxes with `blueprint_name: "axon-agents"` (see [`src/server/acp-manager.ts`](src/server/acp-manager.ts), [`src/server/claude-manager.ts`](src/server/claude-manager.ts), and [`src/server/codex-manager.ts`](src/server/codex-manager.ts)). That blueprint must exist on your Runloop account before starting an agent from the UI — otherwise `POST /api/start` will fail when creating the devbox. Codex agents require a blueprint built after the Codex CLI was added to the [`Dockerfile`](../blueprint/Dockerfile) — re-run the command below if your `axon-agents` image predates it.
+This example provisions devboxes with `blueprint_name: "axon-agents"` (see [`src/server/acp-manager.ts`](src/server/acp-manager.ts), [`src/server/claude-manager.ts`](src/server/claude-manager.ts), and [`src/server/codex-manager.ts`](src/server/codex-manager.ts), and [`src/server/pi-manager.ts`](src/server/pi-manager.ts)). That blueprint must exist on your Runloop account before starting an agent from the UI — otherwise `POST /api/start` will fail when creating the devbox. Codex and Pi agents require a blueprint built after their CLIs were added to the [`Dockerfile`](../blueprint/Dockerfile) — re-run the command below if your `axon-agents` image predates them.
From the monorepo root:
@@ -66,8 +74,8 @@ Open http://localhost:5176. The Vite dev server proxies `/api/*` and `/ws` to th
## How It Works
-1. **Start an agent** — the setup card lets you choose ACP, Claude, or Codex, configure the agent binary / blueprint, and optionally set a system prompt. `POST /api/start` provisions an Axon channel and devbox, then opens the appropriate SDK connection (`ACPAxonConnection`, `ClaudeAxonConnection`, or `CodexAxonConnection`).
-2. **Send a prompt** — `POST /api/prompt` dispatches to the active connection's `prompt()` (ACP) or `send()` (Claude/Codex) and returns immediately.
+1. **Start an agent** — the setup card lets you choose ACP, Claude, Codex, or Pi, configure the agent binary / blueprint, and optionally set a system prompt. `POST /api/start` provisions an Axon channel and devbox, then opens the appropriate SDK connection (`ACPAxonConnection`, `ClaudeAxonConnection`, `CodexAxonConnection`, or `PiAxonConnection`).
+2. **Send a prompt** — `POST /api/prompt` dispatches to the active connection's `prompt()` (ACP) or `send()` (Claude/Codex/Pi) and returns immediately. For Pi, `send()` resolves once the prompt is *accepted*; the turn ends only when `agent_settled` arrives.
3. **Stream events** — the SDK's `onTimelineEvent` callback fires for every classified event (protocol messages, system turns, unknowns). The server broadcasts each event over WebSocket with an `agentId` tag.
4. **Render blocks** — the React client filters events by `agentId`, builds incremental turn blocks (`useBlockManager`), and renders them through `AssistantTurn` / `TurnBlocks`.
@@ -84,6 +92,7 @@ src/
│ ├── acp-client.ts ACP Client implementation (permissions, elicitation)
│ ├── claude-manager.ts Claude connection lifecycle
│ ├── codex-manager.ts Codex connection lifecycle (threads, approvals)
+│ ├── pi-manager.ts Pi connection lifecycle (sessions, steer/follow-up)
│ └── agent-registry.ts Multi-agent bookkeeping
└── client/
├── main.tsx React entry point
@@ -94,6 +103,7 @@ src/
│ ├── useACPAgent.ts ACP event handling and state
│ ├── useClaudeAgent.ts Claude event handling and state
│ ├── useCodexAgent.ts Codex event handling and state (items, approvals)
+│ ├── usePiAgent.ts Pi event handling and state (streaming deltas, tools)
│ ├── useBlockManager.ts Turn block accumulation
│ ├── useAgentList.ts Agent list polling
│ ├── useAttachments.ts File/image attachment handling
diff --git a/examples/combined-app/src/client/App.css b/examples/combined-app/src/client/App.css
index 0228541..d128aad 100644
--- a/examples/combined-app/src/client/App.css
+++ b/examples/combined-app/src/client/App.css
@@ -1875,6 +1875,7 @@ html, body, #root {
.tl-kind-acp { background: rgba(63, 185, 80, 0.15); color: var(--success); }
.tl-kind-claude { background: rgba(136, 87, 255, 0.15); color: #a78bfa; }
.tl-kind-codex { background: rgba(240, 246, 252, 0.12); color: #e6edf3; }
+.tl-kind-pi { background: rgba(249, 115, 22, 0.15); color: #fb923c; }
.tl-kind-unknown { background: rgba(139, 148, 158, 0.15); color: var(--text-secondary); }
.tl-kind-custom { background: rgba(56, 189, 248, 0.15); color: #38bdf8; }
diff --git a/examples/combined-app/src/client/App.tsx b/examples/combined-app/src/client/App.tsx
index 767c75c..b47a06d 100644
--- a/examples/combined-app/src/client/App.tsx
+++ b/examples/combined-app/src/client/App.tsx
@@ -243,12 +243,18 @@ export default function App() {
autoApprovePermissions: startAutoApprove,
...sharedConfig,
}
- : {
- blueprintName: blueprintName || undefined,
- model: model || undefined,
- dangerouslySkipPermissions: startAutoApprove,
- ...sharedConfig,
- };
+ : selectedAgentType === "pi"
+ ? {
+ blueprintName: blueprintName || undefined,
+ model: model || undefined,
+ ...sharedConfig,
+ }
+ : {
+ blueprintName: blueprintName || undefined,
+ model: model || undefined,
+ dangerouslySkipPermissions: startAutoApprove,
+ ...sharedConfig,
+ };
try {
const resp = await api<{ agentId: string; agentType: AgentType; [key: string]: unknown }>(
@@ -266,7 +272,9 @@ export default function App() {
? (blueprintName || "Claude Agent")
: selectedAgentType === "codex"
? (blueprintName || "Codex Agent")
- : (agentBinary || "ACP Agent"),
+ : selectedAgentType === "pi"
+ ? (blueprintName || "Pi Agent")
+ : (agentBinary || "ACP Agent"),
axonId: resp.axonId as string,
devboxId: resp.devboxId as string,
createdAt: Date.now(),
@@ -430,7 +438,9 @@ export default function App() {
? "Claude Code"
: agent.agentType === "codex"
? "Codex"
- : "ACP Agent";
+ : agent.agentType === "pi"
+ ? "Pi"
+ : "ACP Agent";
// Derive devbox status from the last devbox_lifecycle system event in messages
const lastDevboxEvent = [...agent.messages].reverse().find(
@@ -510,6 +520,12 @@ export default function App() {
)}
+ {agent.agentType === "pi" && agent.sessionId && (
+
+ Session: {agent.sessionId}
+
+ )}
+
{agent.agentType === "codex" && agent.threadId && (
Thread: {agent.threadId}
diff --git a/examples/combined-app/src/client/components/AgentSidebar.tsx b/examples/combined-app/src/client/components/AgentSidebar.tsx
index 8fdd794..dd8aa9c 100644
--- a/examples/combined-app/src/client/components/AgentSidebar.tsx
+++ b/examples/combined-app/src/client/components/AgentSidebar.tsx
@@ -42,7 +42,13 @@ export function AgentSidebar({
>
- {agent.agentType === "claude" ? "C" : agent.agentType === "codex" ? "X" : "A"}
+ {agent.agentType === "claude"
+ ? "C"
+ : agent.agentType === "codex"
+ ? "X"
+ : agent.agentType === "pi"
+ ? "P"
+ : "A"}
{agent.name}
Combined App
- Launch a Claude Code , Codex , or ACP agent in a secure cloud sandbox and interact through a unified interface.
+ Launch a Claude Code , Codex , Pi , or ACP agent in a secure cloud sandbox and interact through a unified interface.
@@ -86,6 +86,13 @@ export function SetupCard({
>
Codex
+
setAgentType("pi")}
+ disabled={connecting}
+ >
+ Pi
+
setAgentType("acp")}
@@ -111,7 +118,7 @@ export function SetupCard({
>
)}
- {(agentType === "claude" || agentType === "codex") && (
+ {(agentType === "claude" || agentType === "codex" || agentType === "pi") && (
<>
Blueprint Name
@@ -121,12 +128,22 @@ export function SetupCard({
@@ -160,7 +177,9 @@ export function SetupCard({
? "Skip permissions (--dangerously-skip-permissions)"
: agentType === "codex"
? "Auto-approve approvals"
- : "Auto-approve permissions"}
+ : agentType === "pi"
+ ? "Pi runs tools unattended (no approval protocol)"
+ : "Auto-approve permissions"}
diff --git a/examples/combined-app/src/client/components/TimelineEventItem.tsx b/examples/combined-app/src/client/components/TimelineEventItem.tsx
index 95647e2..3c04847 100644
--- a/examples/combined-app/src/client/components/TimelineEventItem.tsx
+++ b/examples/combined-app/src/client/components/TimelineEventItem.tsx
@@ -28,12 +28,19 @@ import type {
SDKSystemMessage,
} from "@runloop/remote-agents-sdk/claude";
import type { CodexProtocolTimelineEvent } from "@runloop/remote-agents-sdk/codex";
+import type { PiProtocolTimelineEvent } from "@runloop/remote-agents-sdk/pi";
import type { AgentStartedPayload, TimelineEvent } from "../types.js";
import { PayloadTree, formatTime, originLabel, originBadgeClass } from "./shared.js";
const isAgentStartedEvent = createCustomEventGuard("agent_started");
-type TimelineKind = "system" | "acp_protocol" | "claude_protocol" | "codex_protocol" | "unknown";
+type TimelineKind =
+ | "system"
+ | "acp_protocol"
+ | "claude_protocol"
+ | "codex_protocol"
+ | "pi_protocol"
+ | "unknown";
interface TimelineSummary {
icon: string;
@@ -172,6 +179,53 @@ function summarizeCodexProtocol(event: CodexProtocolTimelineEvent): TimelineSumm
}
}
+function summarizePiProtocol(event: PiProtocolTimelineEvent): TimelineSummary {
+ switch (event.eventType) {
+ case "agent_start":
+ return { icon: "\u{1F195}", label: "agent_start", summary: "", kindClass: "kind-protocol" };
+ case "turn_start":
+ return { icon: "▶️", label: "turn_start", summary: "", kindClass: "kind-protocol" };
+ case "turn_end":
+ return { icon: "✅", label: "turn_end", summary: "", kindClass: "kind-protocol" };
+ case "message_start":
+ case "message_end":
+ return { icon: "\u{1F4E6}", label: event.eventType, summary: event.data.message.role, kindClass: "kind-protocol" };
+ case "message_update": {
+ const delta = event.data.assistantMessageEvent;
+ switch (delta.type) {
+ case "text_delta":
+ return { icon: "\u{1F916}", label: "text_delta", summary: delta.delta.slice(0, 40), kindClass: "kind-protocol" };
+ case "thinking_delta":
+ return { icon: "\u{1F4AD}", label: "thinking_delta", summary: "", kindClass: "kind-protocol" };
+ case "error":
+ return { icon: "⚠️", label: "message error", summary: delta.reason, kindClass: "kind-system" };
+ default:
+ return { icon: "\u{1F4E6}", label: delta.type, summary: "", kindClass: "kind-protocol" };
+ }
+ }
+ case "tool_execution_start":
+ case "tool_execution_update":
+ case "tool_execution_end":
+ return { icon: "\u{1F527}", label: event.eventType, summary: event.data.toolName, kindClass: "kind-protocol" };
+ case "agent_end":
+ // Not a turn boundary: `willRetry` means Pi keeps going.
+ return { icon: "⏸️", label: "agent_end", summary: event.data.willRetry ? "willRetry" : "", kindClass: "kind-protocol" };
+ case "agent_settled":
+ return { icon: "⏹️", label: "agent_settled", summary: "", kindClass: "kind-protocol" };
+ case "response": {
+ const { command, success, error } = event.data;
+ return {
+ icon: success ? "✅" : "⚠️",
+ label: `response ${command}`,
+ summary: success ? "" : (error ?? "failed"),
+ kindClass: success ? "kind-protocol" : "kind-system",
+ };
+ }
+ default:
+ return { icon: "\u{1F4E6}", label: (event as { eventType: string }).eventType, summary: "", kindClass: "kind-protocol" };
+ }
+}
+
function summarizeTimelineEvent(event: TimelineEvent): TimelineSummary {
if (isTurnStartedEvent(event)) {
return { icon: "\u25B6\uFE0F", label: "turn.started", summary: "", kindClass: "kind-system" };
@@ -204,6 +258,8 @@ function summarizeTimelineEvent(event: TimelineEvent): TimelineSummary {
return summarizeClaudeProtocol(event);
case "codex_protocol":
return summarizeCodexProtocol(event);
+ case "pi_protocol":
+ return summarizePiProtocol(event);
case "unknown": {
if (isAgentStartedEvent(event)) {
return { icon: "\u2699\uFE0F", label: "Agent Started", summary: event.data.agentType ?? "", kindClass: "kind-custom" };
@@ -226,6 +282,7 @@ function kindBadgeLabel(kind: TimelineKind, custom: boolean): string {
case "acp_protocol": return "ACP";
case "claude_protocol": return "CLAUDE";
case "codex_protocol": return "CODEX";
+ case "pi_protocol": return "PI";
case "unknown": return "?";
}
}
@@ -237,6 +294,7 @@ function kindBadgeClass(kind: TimelineKind, custom: boolean): string {
case "acp_protocol": return "tl-kind-acp";
case "claude_protocol": return "tl-kind-claude";
case "codex_protocol": return "tl-kind-codex";
+ case "pi_protocol": return "tl-kind-pi";
case "unknown": return "tl-kind-unknown";
}
}
diff --git a/examples/combined-app/src/client/hooks/useAgent.ts b/examples/combined-app/src/client/hooks/useAgent.ts
index 2275b70..3be354e 100644
--- a/examples/combined-app/src/client/hooks/useAgent.ts
+++ b/examples/combined-app/src/client/hooks/useAgent.ts
@@ -2,6 +2,7 @@ import { useCallback } from "react";
import { useClaudeAgent } from "./useClaudeAgent.js";
import { useACPAgent } from "./useACPAgent.js";
import { useCodexAgent } from "./useCodexAgent.js";
+import { usePiAgent } from "./usePiAgent.js";
import type { AgentType, IdleAgentState, UseAgentReturn } from "../types.js";
const NOOP_ASYNC = async () => {};
@@ -36,6 +37,7 @@ export function useAgent(agentId: string | null, agentType: AgentType | null): U
const claude = useClaudeAgent(agentType === "claude" ? agentId : null);
const acp = useACPAgent(agentType === "acp" ? agentId : null);
const codex = useCodexAgent(agentType === "codex" ? agentId : null);
+ const pi = usePiAgent(agentType === "pi" ? agentId : null);
const shutdown = useCallback(async () => {
if (agentType === "claude") {
@@ -44,8 +46,10 @@ export function useAgent(agentId: string | null, agentType: AgentType | null): U
await acp.shutdown();
} else if (agentType === "codex") {
await codex.shutdown();
+ } else if (agentType === "pi") {
+ await pi.shutdown();
}
- }, [agentType, claude.shutdown, acp.shutdown, codex.shutdown]);
+ }, [agentType, claude.shutdown, acp.shutdown, codex.shutdown, pi.shutdown]);
if (agentType === "claude") {
const { shutdown: _, ...rest } = claude;
@@ -67,6 +71,16 @@ export function useAgent(agentId: string | null, agentType: AgentType | null): U
};
}
+ if (agentType === "pi") {
+ const { shutdown: _, ...rest } = pi;
+ return {
+ ...rest,
+ agentType: "pi" as const,
+ shutdown,
+ availableCommands: [],
+ };
+ }
+
if (agentType === "acp") {
const { shutdown: _, setModel, respondToElicitation, ...rest } = acp;
return {
diff --git a/examples/combined-app/src/client/hooks/useAgentList.ts b/examples/combined-app/src/client/hooks/useAgentList.ts
index e069b2c..c76c502 100644
--- a/examples/combined-app/src/client/hooks/useAgentList.ts
+++ b/examples/combined-app/src/client/hooks/useAgentList.ts
@@ -3,7 +3,7 @@ import { api } from "./api.js";
export interface AgentListItem {
id: string;
- agentType: "claude" | "acp" | "codex";
+ agentType: "claude" | "acp" | "codex" | "pi";
name: string;
axonId: string;
devboxId: string;
diff --git a/examples/combined-app/src/client/hooks/usePiAgent.ts b/examples/combined-app/src/client/hooks/usePiAgent.ts
new file mode 100644
index 0000000..3ac9645
--- /dev/null
+++ b/examples/combined-app/src/client/hooks/usePiAgent.ts
@@ -0,0 +1,495 @@
+import { useReducer, useRef, useCallback, useEffect } from "react";
+import {
+ isPiProtocolEvent,
+ isTurnStartedEvent,
+ isTurnCompletedEvent,
+} from "@runloop/remote-agents-sdk/pi";
+import type {
+ AgentMessage,
+ PiProtocolTimelineEvent,
+ PiSessionState,
+ PiTimelineEvent,
+} from "@runloop/remote-agents-sdk/pi";
+import { isFromUser, tryParseTimelinePayload } from "@runloop/remote-agents-sdk/shared";
+import type { WsEvent } from "../../shared/ws-events.js";
+import type {
+ TurnBlock,
+ ChatItem,
+ UsageState,
+ AxonEventView,
+ ToolCallBlock,
+ PiInitExtensions,
+} from "../types.js";
+import { nextBlockId } from "./parsers.js";
+import { useBlockManager } from "./useBlockManager.js";
+import { buildAgentConfigItem, buildSystemEventItem } from "./timeline-helpers.js";
+import { api } from "./api.js";
+
+export interface UsePiAgentReturn {
+ connectionPhase: "idle" | "connecting" | "ready" | "error";
+ connectionStatus: string | null;
+ error: string | null;
+ messages: ChatItem[];
+ currentTurnBlocks: TurnBlock[];
+ isAgentTurn: boolean;
+ isStreaming: boolean;
+ isSendingPrompt: boolean;
+ usage: UsageState | null;
+ sessionId: string | null;
+ sessionFile: string | null;
+ devboxId: string | null;
+ axonId: string | null;
+ runloopUrl: string | null;
+ axonEvents: AxonEventView[];
+ timelineEvents: PiTimelineEvent[];
+ autoApprovePermissions: boolean;
+ sendMessage: (text: string, content?: Array<{ type: string; [key: string]: unknown }>) => Promise;
+ cancel: () => Promise;
+ setAutoApprovePermissions: (enabled: boolean) => Promise;
+ shutdown: () => Promise;
+}
+
+interface PiState {
+ connectionPhase: "idle" | "connecting" | "ready" | "error";
+ connectionStatus: string | null;
+ error: string | null;
+ isSendingPrompt: boolean;
+ messages: ChatItem[];
+ isAgentTurn: boolean;
+ isStreaming: boolean;
+ usage: UsageState | null;
+ sessionId: string | null;
+ sessionFile: string | null;
+ devboxId: string | null;
+ axonId: string | null;
+ runloopUrl: string | null;
+ axonEvents: AxonEventView[];
+ timelineEvents: PiTimelineEvent[];
+}
+
+const INITIAL_PI_STATE: PiState = {
+ connectionPhase: "idle",
+ connectionStatus: null,
+ error: null,
+ isSendingPrompt: false,
+ messages: [],
+ isAgentTurn: false,
+ isStreaming: false,
+ usage: null,
+ sessionId: null,
+ sessionFile: null,
+ devboxId: null,
+ axonId: null,
+ runloopUrl: null,
+ axonEvents: [],
+ timelineEvents: [],
+};
+
+type PiAction =
+ | { type: "RESET" }
+ | { type: "SET"; patch: Partial }
+ | { type: "APPEND_MESSAGE"; message: ChatItem }
+ | { type: "APPEND_TIMELINE_EVENT"; event: PiTimelineEvent };
+
+function piReducer(state: PiState, action: PiAction): PiState {
+ switch (action.type) {
+ case "RESET":
+ return INITIAL_PI_STATE;
+ case "SET":
+ return { ...state, ...action.patch };
+ case "APPEND_MESSAGE":
+ return { ...state, messages: [...state.messages, action.message] };
+ case "APPEND_TIMELINE_EVENT":
+ return {
+ ...state,
+ timelineEvents: [...state.timelineEvents, action.event],
+ axonEvents: [...state.axonEvents, action.event.axonEvent],
+ };
+ }
+}
+
+/** Pi reports tool arguments and results as `unknown`; render them readably. */
+function stringifyToolPayload(value: unknown): string {
+ if (value == null) return "";
+ if (typeof value === "string") return value;
+ return JSON.stringify(value, null, 2);
+}
+
+function toolTitleFromArgs(toolName: string, args: unknown): string {
+ if (args && typeof args === "object" && "command" in args) {
+ const command = (args as { command?: unknown }).command;
+ if (typeof command === "string" && command) return command;
+ }
+ return toolName;
+}
+
+export function usePiAgent(agentId: string | null): UsePiAgentReturn {
+ const [s, dispatch] = useReducer(piReducer, INITIAL_PI_STATE);
+ const blocks = useBlockManager();
+ const wsRef = useRef(null);
+ // Pi identifies streamed content by contentIndex within the current assistant
+ // message, and tool executions by toolCallId — both map to a turn block here.
+ const blockIndexRef = useRef>(new Map());
+ const sawInitRef = useRef(false);
+
+ function resetAllState() {
+ blocks.reset();
+ blockIndexRef.current.clear();
+ sawInitRef.current = false;
+ dispatch({ type: "RESET" });
+ }
+
+ function finalizeTurn(stopReason?: string) {
+ const msg = blocks.flushToMessage(stopReason ? { stopReason } : {});
+ if (msg) {
+ dispatch({ type: "APPEND_MESSAGE", message: msg });
+ }
+ blockIndexRef.current.clear();
+ dispatch({ type: "SET", patch: { isAgentTurn: false, isStreaming: false } });
+ }
+
+ function appendToBlock(key: string, delta: string, blockType: "text" | "thinking") {
+ const blockId = blockIndexRef.current.get(key);
+ if (!blockId) {
+ const newId = nextBlockId(blockType === "text" ? "txt" : "think");
+ blockIndexRef.current.set(key, newId);
+ if (blockType === "text") {
+ blocks.finalizeThinking();
+ blocks.pushBlock({ type: "text", id: newId, text: delta });
+ } else {
+ blocks.thinkingStartRef.current ??= Date.now();
+ blocks.pushBlock({ type: "thinking", id: newId, text: delta, duration: null, isActive: true });
+ }
+ dispatch({ type: "SET", patch: { isAgentTurn: true, isStreaming: blockType === "text" } });
+ return;
+ }
+ blocks.updateBlocks((prev) =>
+ prev.map((b) =>
+ b.id === blockId && b.type === blockType ? { ...b, text: b.text + delta } : b,
+ ),
+ );
+ }
+
+ /** An assistant message ended: record its usage and close any thinking block. */
+ function handleMessageEnd(message: AgentMessage) {
+ // contentIndex restarts at 0 for the next message, so the delta keys from
+ // this one must not be reused.
+ for (const key of [...blockIndexRef.current.keys()]) {
+ if (key.startsWith("text:") || key.startsWith("thinking:")) {
+ blockIndexRef.current.delete(key);
+ }
+ }
+ if (message.role !== "assistant") return;
+ blocks.finalizeThinking();
+ dispatch({
+ type: "SET",
+ patch: {
+ isStreaming: false,
+ usage: {
+ inputTokens: message.usage.input,
+ outputTokens: message.usage.output,
+ cacheReadInputTokens: message.usage.cacheRead,
+ cacheCreationInputTokens: message.usage.cacheWrite,
+ cost: message.usage.cost.total,
+ },
+ },
+ });
+ }
+
+ function handleGetStateResponse(state: PiSessionState) {
+ dispatch({
+ type: "SET",
+ patch: { sessionId: state.sessionId, sessionFile: state.sessionFile ?? null },
+ });
+ if (sawInitRef.current) return;
+ sawInitRef.current = true;
+ const extensions: PiInitExtensions = {
+ protocol: "pi",
+ sessionId: state.sessionId,
+ sessionFile: state.sessionFile ?? null,
+ thinkingLevel: state.thinkingLevel,
+ };
+ blocks.pushBlock({
+ type: "system_init",
+ id: nextBlockId("init"),
+ agentName: "Pi",
+ agentVersion: null,
+ model: state.model?.id ?? null,
+ commands: [],
+ extensions,
+ extra: { state },
+ });
+ }
+
+ function handlePiProtocolEvent(event: PiProtocolTimelineEvent): void {
+ switch (event.eventType) {
+ case "response": {
+ // The broker issues `get_state` at spawn and after every turn, so its
+ // acknowledgements are where session identity and the model come from.
+ if (!event.data.success) {
+ dispatch({ type: "SET", patch: { error: event.data.error ?? "Pi command failed" } });
+ break;
+ }
+ if (event.data.command === "get_state" && event.data.data) {
+ handleGetStateResponse(event.data.data as PiSessionState);
+ }
+ break;
+ }
+ case "turn_start":
+ case "agent_start":
+ dispatch({ type: "SET", patch: { isAgentTurn: true } });
+ break;
+ case "message_update": {
+ const delta = event.data.assistantMessageEvent;
+ switch (delta.type) {
+ case "text_delta":
+ appendToBlock(`text:${delta.contentIndex}`, delta.delta, "text");
+ break;
+ case "thinking_delta":
+ appendToBlock(`thinking:${delta.contentIndex}`, delta.delta, "thinking");
+ break;
+ case "error":
+ dispatch({
+ type: "SET",
+ patch: { error: delta.error.errorMessage ?? `Assistant message ${delta.reason}` },
+ });
+ break;
+ default:
+ // Start/end markers and tool-call deltas need no rendering: the
+ // tool_execution_* events carry the authoritative call.
+ break;
+ }
+ break;
+ }
+ case "message_end":
+ handleMessageEnd(event.data.message);
+ break;
+ case "tool_execution_start": {
+ const { toolCallId, toolName, args } = event.data;
+ const blockId = nextBlockId("tc");
+ blockIndexRef.current.set(`tool:${toolCallId}`, blockId);
+ blocks.finalizeThinking();
+ blocks.pushBlock({
+ type: "tool_call",
+ id: blockId,
+ toolCallId,
+ title: toolTitleFromArgs(toolName, args),
+ kind: "other",
+ status: "in_progress",
+ locations: [],
+ content: [],
+ rawInput: args,
+ rawOutput: null,
+ startedAt: Date.now(),
+ duration: null,
+ extra: { toolName },
+ });
+ break;
+ }
+ case "tool_execution_update": {
+ const blockId = blockIndexRef.current.get(`tool:${event.data.toolCallId}`);
+ if (!blockId) break;
+ const partial = stringifyToolPayload(event.data.partialResult);
+ blocks.updateBlocks((prev) =>
+ prev.map((b) =>
+ b.id === blockId && b.type === "tool_call" ? { ...b, rawOutput: partial } : b,
+ ),
+ );
+ break;
+ }
+ case "tool_execution_end": {
+ const blockId = blockIndexRef.current.get(`tool:${event.data.toolCallId}`);
+ if (!blockId) break;
+ const output = stringifyToolPayload(event.data.result);
+ const isError = event.data.isError;
+ blocks.updateBlocks((prev) =>
+ prev.map((b) => {
+ if (b.id !== blockId || b.type !== "tool_call") return b;
+ const tc = b as ToolCallBlock;
+ return {
+ ...tc,
+ status: isError ? ("failed" as const) : ("completed" as const),
+ rawOutput: output || tc.rawOutput,
+ content: output ? [{ type: "content" as const, text: output }] : tc.content,
+ duration: Math.round((Date.now() - tc.startedAt) / 100) / 10,
+ };
+ }),
+ );
+ break;
+ }
+ case "agent_settled":
+ // The only event that ends a Pi turn. `agent_end` does not: Pi may
+ // auto-retry after it and keep streaming.
+ finalizeTurn();
+ break;
+ default:
+ // message_start adds nothing over the deltas that follow it; turn_end
+ // and agent_end are informational until agent_settled arrives.
+ break;
+ }
+ }
+
+ function handleTimelineEvent(tlEvent: PiTimelineEvent): void {
+ dispatch({ type: "APPEND_TIMELINE_EVENT", event: tlEvent });
+
+ // The client's own turn/start frames echo back as USER_EVENTs — render them
+ // as user chat messages.
+ if (isFromUser(tlEvent.axonEvent) && tlEvent.axonEvent.event_type === "turn/start") {
+ const frame = tryParseTimelinePayload<{ message?: string }>(tlEvent);
+ if (frame?.message) {
+ dispatch({ type: "APPEND_MESSAGE", message: {
+ id: `user-${tlEvent.axonEvent.sequence}`,
+ role: "user" as const,
+ content: frame.message,
+ } });
+ }
+ return;
+ }
+
+ if (isTurnStartedEvent(tlEvent)) {
+ dispatch({ type: "SET", patch: { isAgentTurn: true } });
+ return;
+ }
+
+ if (isTurnCompletedEvent(tlEvent)) {
+ dispatch({ type: "SET", patch: { isAgentTurn: false, isStreaming: false } });
+ return;
+ }
+
+ if (isPiProtocolEvent(tlEvent)) {
+ handlePiProtocolEvent(tlEvent);
+ return;
+ }
+
+ const sysItem = buildSystemEventItem(tlEvent);
+ if (sysItem) {
+ dispatch({ type: "APPEND_MESSAGE", message: sysItem });
+ return;
+ }
+
+ const agentConfig = buildAgentConfigItem(tlEvent);
+ if (agentConfig) {
+ dispatch({ type: "APPEND_MESSAGE", message: agentConfig });
+ }
+ }
+
+ useEffect(() => {
+ resetAllState();
+
+ if (!agentId) {
+ wsRef.current?.close();
+ wsRef.current = null;
+ dispatch({ type: "SET", patch: { connectionPhase: "idle" } });
+ return;
+ }
+
+ dispatch({ type: "SET", patch: { connectionPhase: "connecting" } });
+
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/ws`;
+ const socket = new WebSocket(wsUrl);
+ wsRef.current = socket;
+
+ socket.onmessage = (ev) => {
+ let parsed: WsEvent;
+ try {
+ parsed = JSON.parse(ev.data);
+ } catch {
+ return;
+ }
+
+ if (parsed.agentId !== agentId) return;
+
+ if (parsed.type === "timeline_event") {
+ handleTimelineEvent(parsed.event as PiTimelineEvent);
+ return;
+ }
+
+ if (parsed.type === "connection_progress") {
+ dispatch({ type: "SET", patch: { connectionStatus: parsed.step } });
+ return;
+ }
+
+ if (parsed.type === "turn_error") {
+ finalizeTurn();
+ dispatch({ type: "SET", patch: { error: parsed.error } });
+ }
+ };
+
+ socket.onopen = () => {
+ dispatch({ type: "SET", patch: { connectionPhase: "ready" } });
+ api("/api/subscribe", { agentId }).catch(() => {});
+ };
+
+ socket.onclose = () => {
+ wsRef.current = null;
+ };
+
+ return () => {
+ socket.close();
+ wsRef.current = null;
+ };
+ }, [agentId]);
+
+ const sendMessage = useCallback(async (text: string, content?: Array<{ type: string; [key: string]: unknown }>) => {
+ if (!text.trim() && (!content || content.length === 0)) return;
+
+ blocks.reset();
+ blockIndexRef.current.clear();
+ dispatch({ type: "SET", patch: { isAgentTurn: true, isStreaming: false, isSendingPrompt: true } });
+
+ try {
+ if (content && content.length > 0) {
+ await api("/api/prompt", { agentId, content });
+ } else {
+ await api("/api/prompt", { agentId, text });
+ }
+ } catch (err) {
+ dispatch({ type: "SET", patch: { error: err instanceof Error ? err.message : String(err) } });
+ } finally {
+ dispatch({ type: "SET", patch: { isSendingPrompt: false } });
+ }
+ }, [agentId]);
+
+ const cancel = useCallback(async () => {
+ try { await api("/api/cancel", { agentId }); } catch (err) {
+ dispatch({ type: "SET", patch: { error: err instanceof Error ? err.message : String(err) } });
+ }
+ }, [agentId]);
+
+ // Pi has no approval protocol — every tool runs unattended — so the toggle is
+ // display-only here.
+ const setAutoApprovePermissions = useCallback(async () => {}, []);
+
+ const shutdown = useCallback(async () => {
+ try { await api("/api/shutdown", { agentId }); } catch { /* ignore */ }
+ wsRef.current?.close();
+ wsRef.current = null;
+ dispatch({ type: "SET", patch: { connectionPhase: "idle" } });
+ resetAllState();
+ }, [agentId]);
+
+ return {
+ connectionPhase: s.connectionPhase,
+ connectionStatus: s.connectionStatus,
+ error: s.error,
+ messages: s.messages,
+ currentTurnBlocks: blocks.currentTurnBlocks,
+ isAgentTurn: s.isAgentTurn,
+ isStreaming: s.isStreaming,
+ isSendingPrompt: s.isSendingPrompt,
+ usage: s.usage,
+ sessionId: s.sessionId,
+ sessionFile: s.sessionFile,
+ devboxId: s.devboxId,
+ axonId: s.axonId,
+ runloopUrl: s.runloopUrl,
+ axonEvents: s.axonEvents,
+ timelineEvents: s.timelineEvents,
+ autoApprovePermissions: true,
+ sendMessage,
+ cancel,
+ setAutoApprovePermissions,
+ shutdown,
+ };
+}
diff --git a/examples/combined-app/src/client/types.ts b/examples/combined-app/src/client/types.ts
index 5f74f44..5149dd2 100644
--- a/examples/combined-app/src/client/types.ts
+++ b/examples/combined-app/src/client/types.ts
@@ -19,6 +19,7 @@ import type {
import type { ACPTimelineEvent, AxonEventView } from "@runloop/remote-agents-sdk/acp";
import type { ClaudeTimelineEvent, SDKControlRequest } from "@runloop/remote-agents-sdk/claude";
import type { ApprovalRequest, CodexTimelineEvent } from "@runloop/remote-agents-sdk/codex";
+import type { PiTimelineEvent } from "@runloop/remote-agents-sdk/pi";
export type {
AgentCapabilities,
@@ -43,10 +44,15 @@ export type {
export type { ACPTimelineEvent, AxonEventView } from "@runloop/remote-agents-sdk/acp";
export type { ClaudeTimelineEvent } from "@runloop/remote-agents-sdk/claude";
export type { CodexTimelineEvent } from "@runloop/remote-agents-sdk/codex";
+export type { PiTimelineEvent } from "@runloop/remote-agents-sdk/pi";
-export type TimelineEvent = ACPTimelineEvent | ClaudeTimelineEvent | CodexTimelineEvent;
+export type TimelineEvent =
+ | ACPTimelineEvent
+ | ClaudeTimelineEvent
+ | CodexTimelineEvent
+ | PiTimelineEvent;
-export type AgentType = "claude" | "acp" | "codex";
+export type AgentType = "claude" | "acp" | "codex" | "pi";
export type ConnectionPhase = "idle" | "connecting" | "ready" | "error";
@@ -192,6 +198,13 @@ export interface CodexInitExtensions {
cwd: string | null;
}
+export interface PiInitExtensions {
+ protocol: "pi";
+ sessionId: string | null;
+ sessionFile: string | null;
+ thinkingLevel: string | null;
+}
+
export interface SystemInitBlock {
type: "system_init";
id: string;
@@ -199,7 +212,12 @@ export interface SystemInitBlock {
agentVersion: string | null;
model: string | null;
commands: string[];
- extensions: ClaudeInitExtensions | ACPInitExtensions | CodexInitExtensions | null;
+ extensions:
+ | ClaudeInitExtensions
+ | ACPInitExtensions
+ | CodexInitExtensions
+ | PiInitExtensions
+ | null;
extra: Record;
}
@@ -501,8 +519,19 @@ export interface CodexAgentState extends SharedAgentState {
respondToUserInput: (requestId: string, answers: Record) => Promise;
}
+export interface PiAgentState extends SharedAgentState {
+ agentType: "pi";
+ sessionId: string | null;
+ sessionFile: string | null;
+}
+
export interface IdleAgentState extends SharedAgentState {
agentType: null;
}
-export type UseAgentReturn = ClaudeAgentState | ACPAgentState | CodexAgentState | IdleAgentState;
+export type UseAgentReturn =
+ | ClaudeAgentState
+ | ACPAgentState
+ | CodexAgentState
+ | PiAgentState
+ | IdleAgentState;
diff --git a/examples/combined-app/src/server/agent-registry.ts b/examples/combined-app/src/server/agent-registry.ts
index ecdb7d1..e8330e1 100644
--- a/examples/combined-app/src/server/agent-registry.ts
+++ b/examples/combined-app/src/server/agent-registry.ts
@@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto";
import type { ClaudeConnectionManager } from "./claude-manager.ts";
import type { ACPConnectionManager } from "./acp-manager.ts";
import type { CodexConnectionManager } from "./codex-manager.ts";
+import type { PiConnectionManager } from "./pi-manager.ts";
export interface AgentEntry {
id: string;
- agentType: "claude" | "acp" | "codex";
+ agentType: "claude" | "acp" | "codex" | "pi";
name: string;
axonId: string;
devboxId: string;
@@ -13,11 +14,12 @@ export interface AgentEntry {
claudeManager?: ClaudeConnectionManager;
acpManager?: ACPConnectionManager;
codexManager?: CodexConnectionManager;
+ piManager?: PiConnectionManager;
}
export interface AgentListItem {
id: string;
- agentType: "claude" | "acp" | "codex";
+ agentType: "claude" | "acp" | "codex" | "pi";
name: string;
axonId: string;
devboxId: string;
@@ -60,6 +62,7 @@ export class AgentRegistry {
if (entry.claudeManager) await entry.claudeManager.shutdown();
if (entry.acpManager) await entry.acpManager.shutdown();
if (entry.codexManager) await entry.codexManager.shutdown();
+ if (entry.piManager) await entry.piManager.shutdown();
this.agents.delete(id);
}
diff --git a/examples/combined-app/src/server/index.ts b/examples/combined-app/src/server/index.ts
index e6d2ecf..d4e5bd7 100644
--- a/examples/combined-app/src/server/index.ts
+++ b/examples/combined-app/src/server/index.ts
@@ -6,6 +6,7 @@ import { registerClaudeRoutes } from "./routes/claude.ts";
import { registerCodexRoutes } from "./routes/codex.ts";
import { registerDebugRoutes } from "./routes/debug.ts";
import { registerLifecycleRoutes } from "./routes/lifecycle.ts";
+import { registerPiRoutes } from "./routes/pi.ts";
import { registerPromptRoutes } from "./routes/prompt.ts";
import { WsBroadcaster } from "./ws.ts";
@@ -21,6 +22,7 @@ registerPromptRoutes(app, registry, ws);
registerClaudeRoutes(app, registry);
registerACPRoutes(app, registry);
registerCodexRoutes(app, registry);
+registerPiRoutes(app, registry);
registerDebugRoutes(app, registry);
const PORT = process.env.PORT ?? 3003;
@@ -36,4 +38,7 @@ server.listen(PORT, () => {
console.log(
`OPENAI_API_KEY: ${process.env.OPENAI_API_KEY ? "set" : "NOT SET"}, CODEX_AUTH_JSON: ${process.env.CODEX_AUTH_JSON ? "set" : "NOT SET"} (Codex agents need one of these)`,
);
+ console.log(
+ `NEBIUS_API_KEY: ${process.env.NEBIUS_API_KEY ? "set" : "NOT SET"}, NEBIUS_BASE_URL: ${process.env.NEBIUS_BASE_URL ? "set" : "NOT SET"} (Pi agents need both)`,
+ );
});
diff --git a/examples/combined-app/src/server/pi-manager.ts b/examples/combined-app/src/server/pi-manager.ts
new file mode 100644
index 0000000..26958ec
--- /dev/null
+++ b/examples/combined-app/src/server/pi-manager.ts
@@ -0,0 +1,223 @@
+import { RunloopSDK } from "@runloop/api-client";
+import type { Axon, Devbox } from "@runloop/api-client/sdk";
+import { PiAxonConnection, type PiSessionState } from "@runloop/remote-agents-sdk/pi";
+import type { AxonEventView } from "@runloop/remote-agents-sdk/shared";
+import { HttpError } from "./http-errors.ts";
+import type { WsBroadcaster, WsEvent, BaseWsEvent } from "./ws.ts";
+
+export interface PiStartOptions {
+ blueprintName?: string;
+ launchCommands?: string[];
+ /** Extra CLI args for the `pi` binary, appended after the defaults. */
+ launchArgs?: string[];
+ workingDir?: string;
+ model?: string;
+}
+
+// Provider/model id from the models.json written at launch. GLM-5.2 runs on
+// Runloop's dedicated Nebius endpoint.
+const DEFAULT_PI_MODEL = "nebius/glm-5.2";
+
+// Pi resolves custom providers from ~/.pi/agent/models.json. `apiKey` keeps the
+// literal "$NEBIUS_API_KEY" because Pi interpolates $ENV_VAR itself, so the key
+// never lands in the file — only the env var name does. python3's json.dumps
+// handles any characters in the base URL safely; hand-assembling JSON in shell
+// would break on quotes and backslashes.
+const WRITE_PI_MODELS_CMD =
+ 'mkdir -p "$HOME/.pi/agent" && umask 077 && python3 -c \'import json, os; print(json.dumps({"providers": {"nebius": {"baseUrl": os.environ["NEBIUS_BASE_URL"], "api": "openai-completions", "apiKey": "$NEBIUS_API_KEY", "models": [{"id": "glm-5.2", "name": "GLM 5.2 (Nebius)", "reasoning": True, "contextWindow": 262144, "maxTokens": 32000}], "compat": {"thinkingFormat": "zai"}}}}))\' > "$HOME/.pi/agent/models.json"';
+
+export class PiConnectionManager {
+ connection: PiAxonConnection | null = null;
+ axonEvents: AxonEventView[] = [];
+
+ private axon: Axon | null = null;
+ private devbox: Devbox | null = null;
+
+ constructor(
+ private ws: WsBroadcaster,
+ private agentId: string,
+ ) {}
+
+ private tag(event: BaseWsEvent): WsEvent {
+ return { ...event, agentId: this.agentId } as WsEvent;
+ }
+
+ async start(opts: PiStartOptions) {
+ const apiKey = process.env.RUNLOOP_API_KEY;
+ const baseUrl = process.env.RUNLOOP_BASE_URL;
+ const nebiusApiKey = process.env.NEBIUS_API_KEY;
+ const nebiusBaseUrl = process.env.NEBIUS_BASE_URL;
+
+ if (!apiKey) throw new HttpError(401, "RUNLOOP_API_KEY not set in server .env");
+ if (!nebiusApiKey) throw new HttpError(401, "NEBIUS_API_KEY not set in server .env");
+ if (!nebiusBaseUrl) throw new HttpError(401, "NEBIUS_BASE_URL not set in server .env");
+
+ const sdk = new RunloopSDK({
+ bearerToken: apiKey,
+ ...(baseUrl ? { baseURL: baseUrl } : {}),
+ });
+
+ this.ws.broadcast(this.tag({ type: "connection_progress", step: "Creating Axon channel..." }));
+ const axon = await sdk.axon.create({ name: "combined-app-pi" });
+ this.axon = axon;
+
+ // The broker accepts protocol "pi_json", but the published
+ // @runloop/api-client mount types don't include it yet.
+ const brokerProtocol: "acp" | "claude_json" | "pi_json" = "pi_json";
+
+ this.ws.broadcast(this.tag({ type: "connection_progress", step: "Provisioning sandbox..." }));
+ const devbox = await sdk.devbox.create({
+ name: "combined-app-pi",
+ blueprint_name: opts.blueprintName ?? "axon-agents",
+ mounts: [
+ {
+ type: "broker_mount" as const,
+ axon_id: axon.id,
+ protocol: brokerProtocol as "acp" | "claude_json",
+ agent_binary: "/usr/local/bin/pi",
+ // `--mode rpc` and `--session-dir` are broker-owned: the broker
+ // appends both, and `--session-dir` must stay on the durable state
+ // root for resume to survive devbox snapshots. Never set either here.
+ launch_args: ["--model", opts.model ?? DEFAULT_PI_MODEL, ...(opts.launchArgs ?? [])],
+ ...(opts.workingDir ? { working_directory: opts.workingDir } : {}),
+ },
+ ],
+ environment_variables: {
+ NEBIUS_API_KEY: nebiusApiKey,
+ NEBIUS_BASE_URL: nebiusBaseUrl,
+ },
+ launch_parameters: {
+ launch_commands: [WRITE_PI_MODELS_CMD, ...(opts.launchCommands ?? [])],
+ lifecycle: {
+ after_idle: {
+ idle_time_seconds: 60,
+ on_idle: "suspend",
+ },
+ resume_triggers: {
+ axon_event: true,
+ },
+ },
+ },
+ });
+
+ this.devbox = devbox;
+
+ this.ws.broadcast(this.tag({ type: "connection_progress", step: "Connecting to Pi..." }));
+ // Pi has no handshake, so connect() is the whole setup — there is no
+ // initialize() to run here.
+ const conn = this.wireConnection(axon, devbox, {
+ onDisconnect: async () => {
+ await devbox.shutdown();
+ },
+ });
+ await conn.connect();
+
+ return {
+ devboxId: devbox.id,
+ axonId: axon.id,
+ sessionId: conn.sessionId ?? null,
+ runloopUrl: baseUrl ?? "https://platform.runloop.ai",
+ };
+ }
+
+ private wireConnection(
+ axon: Axon,
+ devbox: Devbox,
+ opts?: { onDisconnect?: () => Promise; afterSequence?: number },
+ ): PiAxonConnection {
+ // A fresh wire replays full history for the client; a resume-from-sequence
+ // rewire keeps the accumulated events so the UI sees no duplicates.
+ if (opts?.afterSequence == null) this.axonEvents = [];
+
+ const conn = new PiAxonConnection(axon, devbox, {
+ verbose: true,
+ ...(opts?.afterSequence != null ? { afterSequence: opts.afterSequence, replay: false } : {}),
+ ...(opts?.onDisconnect ? { onDisconnect: opts.onDisconnect } : {}),
+ });
+
+ this.connection = conn;
+
+ conn.onAxonEvent((ev) => {
+ this.axonEvents.push(ev);
+ });
+
+ conn.onTimelineEvent((ev) => {
+ this.ws.broadcast(this.tag({ type: "timeline_event", event: ev }));
+ });
+
+ return conn;
+ }
+
+ async subscribe(): Promise {
+ if (!this.axon || !this.devbox) throw new Error("No axon/devbox — agent not started");
+ if (this.connection) {
+ this.connection.abortStream();
+ }
+ const conn = this.wireConnection(this.axon, this.devbox);
+ await conn.connect();
+ }
+
+ /**
+ * Starts a turn. Resolves when Pi *accepts* the prompt, not when the turn
+ * finishes — the UI tracks completion from the timeline events instead.
+ */
+ async send(prompt: string): Promise {
+ if (!this.connection) throw new HttpError(400, "Not connected");
+ await this.ensureLiveConnection();
+ await this.connection.send(prompt);
+ }
+
+ /**
+ * Queues a message against the turn already in flight: `steer` redirects it,
+ * `follow_up` runs after it settles. Neither starts a turn, so neither goes
+ * through the broker's turn tracking.
+ */
+ async queue(message: string, mode: "steer" | "follow_up"): Promise {
+ if (!this.connection) throw new HttpError(400, "Not connected");
+ await this.ensureLiveConnection();
+ if (mode === "steer") {
+ await this.connection.steer(message);
+ } else {
+ await this.connection.followUp(message);
+ }
+ }
+
+ /** Pi's session snapshot: model, streaming flag, `sessionId`, `sessionFile`. */
+ async getState(): Promise {
+ if (!this.connection) throw new HttpError(400, "Not connected");
+ await this.ensureLiveConnection();
+ return this.connection.getState();
+ }
+
+ /**
+ * Re-wires the connection if its SSE stream silently died (the SDK retries
+ * a dropped stream once per connection lifetime, so a long idle/suspended
+ * devbox can outlive it). Resumes from the last seen sequence so previously
+ * broadcast events are not replayed to the client.
+ */
+ private async ensureLiveConnection(): Promise {
+ if (!this.connection || !this.connection.isDisconnected) return;
+ if (!this.axon || !this.devbox) throw new HttpError(400, "Not connected");
+ console.log("[pi] event stream dropped — re-wiring connection before send");
+ const afterSequence = this.axonEvents.at(-1)?.sequence;
+ const conn = this.wireConnection(this.axon, this.devbox, {
+ ...(afterSequence != null ? { afterSequence } : {}),
+ });
+ await conn.connect();
+ }
+
+ async interrupt(): Promise {
+ if (!this.connection) throw new HttpError(400, "Not connected");
+ await this.connection.interrupt();
+ }
+
+ async shutdown(): Promise {
+ if (this.connection) {
+ await this.connection.disconnect();
+ }
+ this.connection = null;
+ this.axon = null;
+ this.devbox = null;
+ this.axonEvents = [];
+ }
+}
diff --git a/examples/combined-app/src/server/routes/lifecycle.ts b/examples/combined-app/src/server/routes/lifecycle.ts
index 4f8ffee..cf7731b 100644
--- a/examples/combined-app/src/server/routes/lifecycle.ts
+++ b/examples/combined-app/src/server/routes/lifecycle.ts
@@ -3,6 +3,7 @@ import { ACPConnectionManager } from "../acp-manager.ts";
import type { AgentRegistry } from "../agent-registry.ts";
import { ClaudeConnectionManager } from "../claude-manager.ts";
import { CodexConnectionManager } from "../codex-manager.ts";
+import { PiConnectionManager } from "../pi-manager.ts";
import type { WsBroadcaster } from "../ws.ts";
import { asyncHandler, requireAgent } from "./helpers.ts";
@@ -23,6 +24,8 @@ export function registerLifecycleRoutes(app: Express, registry: AgentRegistry, w
await entry.acpManager.subscribe();
} else if (entry.agentType === "codex" && entry.codexManager) {
await entry.codexManager.subscribe();
+ } else if (entry.agentType === "pi" && entry.piManager) {
+ await entry.piManager.subscribe();
}
res.json({ ok: true });
}),
@@ -80,6 +83,29 @@ export function registerLifecycleRoutes(app: Express, registry: AgentRegistry, w
console.error("[agent_started] publish failed:", err),
);
res.json({ agentId, agentType: "codex", ...result });
+ } else if (agentType === "pi") {
+ const manager = new PiConnectionManager(ws, agentId);
+ const result = await manager.start(config);
+ registry.add({
+ id: agentId,
+ agentType: "pi",
+ name: config.blueprintName ?? "Pi Agent",
+ axonId: result.axonId,
+ devboxId: result.devboxId,
+ createdAt: Date.now(),
+ piManager: manager,
+ });
+ manager.connection
+ ?.publish({
+ event_type: "agent_started",
+ origin: "EXTERNAL_EVENT",
+ payload: JSON.stringify({ agentType: "pi", agentId, ...config }),
+ source: "combined-app",
+ })
+ .catch((err: unknown) =>
+ console.error("[agent_started] publish failed:", err),
+ );
+ res.json({ agentId, agentType: "pi", ...result });
} else {
const manager = new ACPConnectionManager(ws, agentId);
const result = await manager.start(config);
diff --git a/examples/combined-app/src/server/routes/pi.ts b/examples/combined-app/src/server/routes/pi.ts
new file mode 100644
index 0000000..1f9ae3b
--- /dev/null
+++ b/examples/combined-app/src/server/routes/pi.ts
@@ -0,0 +1,52 @@
+import type { Express, Response } from "express";
+import type { AgentEntry, AgentRegistry } from "../agent-registry.ts";
+import type { PiConnectionManager } from "../pi-manager.ts";
+import { asyncHandler, requireAgent } from "./helpers.ts";
+
+function requirePiManager(entry: AgentEntry, res: Response): PiConnectionManager | null {
+ if (entry.agentType !== "pi" || !entry.piManager) {
+ res.status(400).json({ error: "Not a Pi session" });
+ return null;
+ }
+ return entry.piManager;
+}
+
+export function registerPiRoutes(app: Express, registry: AgentRegistry) {
+ // Pi's session snapshot. `sessionFile` is the transcript path the broker
+ // persists, so this is how the UI shows that a resumed session is the same
+ // one as before a suspend.
+ app.post(
+ "/api/pi/state",
+ asyncHandler(async (req, res) => {
+ const entry = requireAgent(req, res, registry);
+ if (!entry) return;
+ const manager = requirePiManager(entry, res);
+ if (!manager) return;
+ res.json({ state: await manager.getState() });
+ }),
+ );
+
+ // Steer redirects the in-flight turn; follow-up queues a message for after it
+ // settles. Both are distinct from /api/prompt, which starts a turn and which
+ // Pi rejects while it is streaming.
+ app.post(
+ "/api/pi/queue",
+ asyncHandler(async (req, res) => {
+ const entry = requireAgent(req, res, registry);
+ if (!entry) return;
+ const manager = requirePiManager(entry, res);
+ if (!manager) return;
+ const { message, mode } = req.body;
+ if (typeof message !== "string" || !message.trim()) {
+ res.status(400).json({ error: "message is required" });
+ return;
+ }
+ if (mode !== "steer" && mode !== "follow_up") {
+ res.status(400).json({ error: 'mode must be "steer" or "follow_up"' });
+ return;
+ }
+ await manager.queue(message, mode);
+ res.json({ ok: true });
+ }),
+ );
+}
diff --git a/examples/combined-app/src/server/routes/prompt.ts b/examples/combined-app/src/server/routes/prompt.ts
index b0bd554..024a6ae 100644
--- a/examples/combined-app/src/server/routes/prompt.ts
+++ b/examples/combined-app/src/server/routes/prompt.ts
@@ -119,6 +119,39 @@ export function registerPromptRoutes(app: Express, registry: AgentRegistry, ws:
});
});
+ res.json({ ok: true });
+ } else if (entry.agentType === "pi") {
+ const manager = entry.piManager!;
+ if (!manager.connection) {
+ res.status(400).json({ error: "Not connected" });
+ return;
+ }
+ const { content, text } = req.body;
+
+ const contentItems: Record[] = Array.isArray(content)
+ ? content
+ : [{ type: "text", text }];
+
+ // Pi's prompt command takes one message string, so file attachments are
+ // flattened into it. Images are dropped: this example does not carry
+ // them through.
+ const prompt = contentItems
+ .map((item) =>
+ item.type === "file"
+ ? `--- ${item.name} ---\n${item.text}`
+ : ((item.text ?? "") as string),
+ )
+ .filter((part) => part.length > 0)
+ .join("\n\n");
+
+ manager.send(prompt).catch((err: unknown) => {
+ ws.broadcast({
+ type: "turn_error",
+ agentId: entry.id,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ });
+
res.json({ ok: true });
} else {
const manager = entry.acpManager!;
@@ -185,6 +218,8 @@ export function registerPromptRoutes(app: Express, registry: AgentRegistry, ws:
await entry.claudeManager!.interrupt();
} else if (entry.agentType === "codex") {
await entry.codexManager!.interrupt();
+ } else if (entry.agentType === "pi") {
+ await entry.piManager!.interrupt();
} else {
const { connection, sessionId } = entry.acpManager!.requireSession();
await connection.cancel({ sessionId });
diff --git a/examples/combined-app/src/shared/ws-events.ts b/examples/combined-app/src/shared/ws-events.ts
index 5647bf8..d6682ca 100644
--- a/examples/combined-app/src/shared/ws-events.ts
+++ b/examples/combined-app/src/shared/ws-events.ts
@@ -1,9 +1,13 @@
import type { ACPTimelineEvent, ElicitationRequest, RequestPermissionRequest } from "@runloop/remote-agents-sdk/acp";
import type { ClaudeTimelineEvent, SDKControlRequest } from "@runloop/remote-agents-sdk/claude";
import type { ApprovalRequest, CodexTimelineEvent } from "@runloop/remote-agents-sdk/codex";
+import type { PiTimelineEvent } from "@runloop/remote-agents-sdk/pi";
export type BaseWsEvent =
- | { type: "timeline_event"; event: ACPTimelineEvent | ClaudeTimelineEvent | CodexTimelineEvent }
+ | {
+ type: "timeline_event";
+ event: ACPTimelineEvent | ClaudeTimelineEvent | CodexTimelineEvent | PiTimelineEvent;
+ }
| { type: "connection_progress"; step: string }
| { type: "turn_error"; error: string }
| { type: "control_request"; controlRequest: SDKControlRequest }
diff --git a/examples/feature-examples/README.md b/examples/feature-examples/README.md
index 35ab116..b7d890b 100644
--- a/examples/feature-examples/README.md
+++ b/examples/feature-examples/README.md
@@ -4,7 +4,7 @@ Runnable SDK recipes demonstrating individual features of `@runloop/remote-agent
## Prerequisites
-Most use cases run against `runloop/starter-x86_64` using the **agent-mount** install strategy. The `agent-via-blueprint` use case demonstrates the **blueprint** install strategy where agents are pre-baked into a custom image. Build the shared `axon-agents` blueprint once before running the full suite:
+Most use cases run against `runloop/starter-x86_64` using the **agent-mount** install strategy. The `agent-via-blueprint` use case demonstrates the **blueprint** install strategy where agents are pre-baked into a custom image, and the `pi` agent always uses it (Pi has no catalog agent mount). Build the shared `axon-agents` blueprint once before running the full suite:
```bash
bun run build-blueprint
diff --git a/examples/feature-examples/src/agents.ts b/examples/feature-examples/src/agents.ts
index 29da749..11e9139 100644
--- a/examples/feature-examples/src/agents.ts
+++ b/examples/feature-examples/src/agents.ts
@@ -3,9 +3,10 @@ import type { AgentConfig } from "./types.js";
/**
* Default agent configurations.
*
- * All agents use the "agent-mount" install strategy: a starter blueprint +
- * agent mount to install the agent at provision time. API keys are injected
- * via secrets in scaffold.ts, not here.
+ * Every agent except `pi` uses the "agent-mount" install strategy: a starter
+ * blueprint + agent mount to install the agent at provision time. `pi` has no
+ * catalog mount and comes pre-baked in the `axon-agents` blueprint. API keys
+ * are injected via secrets in scaffold.ts, not here.
*/
export const AGENTS: AgentConfig[] = [
{
@@ -86,6 +87,29 @@ export const AGENTS: AgentConfig[] = [
},
secrets: { GEMINI_API_KEY: "GEMINI_API_KEY" },
},
+ {
+ name: "pi",
+ protocol: "pi",
+ // Pi has no catalog agent mount, so it comes from the `axon-agents`
+ // blueprint (see examples/blueprint/Dockerfile, which pins 0.82.1).
+ install: { kind: "blueprint", blueprint: "axon-agents" },
+ brokerMount: {
+ protocol: "pi_json",
+ agentBinary: "pi",
+ workingDirectory: "/home/user",
+ // `--mode rpc` and `--session-dir` are broker-owned: the broker appends
+ // both, and `--session-dir` must point at the durable state root for
+ // resume to survive devbox snapshots. Never set either here.
+ launchArgs: ["--model", "nebius/glm-5.2"],
+ },
+ // NEBIUS_BASE_URL is Runloop's dedicated endpoint, not a public URL, so it
+ // travels through the secret mechanism too — that keeps it out of the repo
+ // and makes a missing value a clean skip instead of a confusing failure.
+ secrets: {
+ NEBIUS_API_KEY: "NEBIUS_API_KEY",
+ NEBIUS_BASE_URL: "NEBIUS_BASE_URL",
+ },
+ },
{
name: "claude-code",
protocol: "claude",
diff --git a/examples/feature-examples/src/main.ts b/examples/feature-examples/src/main.ts
index 8c2cca0..011f8e7 100644
--- a/examples/feature-examples/src/main.ts
+++ b/examples/feature-examples/src/main.ts
@@ -17,6 +17,10 @@ const FEATURE_EXAMPLES_DIR = resolve(__dirname, "..");
const TEMPLATES_DIR = resolve(FEATURE_EXAMPLES_DIR, "templates");
const SDK_PACKAGE_JSON = resolve(REPO_ROOT, "sdk/package.json");
+/** Selectable protocols, in the column order of compatibility.md.template. */
+const PROTOCOLS = ["acp", "claude", "codex", "pi"] as const;
+type Protocol = (typeof PROTOCOLS)[number];
+
async function getSdkVersion(): Promise {
const content = await readFile(SDK_PACKAGE_JSON, "utf-8");
const pkg = JSON.parse(content) as { version: string };
@@ -55,7 +59,7 @@ Usage: bun run feature-compat [options]
Options:
--agent Run only for this agent (default: all)
- --protocol Run only for this protocol: acp, claude, codex (default: all)
+ --protocol Run only for this protocol: acp, claude, codex, pi (default: all)
--use-case Run only this use case (default: all)
--parallel Max concurrent devboxes (default: 5)
--timeout Default timeout per use case, capped at 30000 (default: 10000)
@@ -236,27 +240,14 @@ function aggregateProtocolStatus(results: RunResult[]): RunResult["status"] | "p
function buildProtocolFeatureRows(results: RunResult[], useCases: UseCase[]): string {
let rows = "";
for (const uc of useCases) {
- const acpResults = results.filter(
- (r) => r.useCase === uc.name && r.protocol === "acp",
- );
- const claudeResults = results.filter(
- (r) => r.useCase === uc.name && r.protocol === "claude",
- );
- const codexResults = results.filter(
- (r) => r.useCase === uc.name && r.protocol === "codex",
- );
+ const cells = PROTOCOLS.map((protocol) => {
+ if (!uc.protocols.includes(protocol)) return "N/A";
+ return aggregateProtocolStatus(
+ results.filter((r) => r.useCase === uc.name && r.protocol === protocol),
+ );
+ });
- const acpStatus = uc.protocols.includes("acp")
- ? aggregateProtocolStatus(acpResults)
- : "N/A";
- const claudeStatus = uc.protocols.includes("claude")
- ? aggregateProtocolStatus(claudeResults)
- : "N/A";
- const codexStatus = uc.protocols.includes("codex")
- ? aggregateProtocolStatus(codexResults)
- : "N/A";
-
- rows += `| ${uc.name} | ${acpStatus} | ${claudeStatus} | ${codexStatus} |\n`;
+ rows += `| ${uc.name} | ${cells.join(" | ")} |\n`;
}
return rows.trimEnd();
}
@@ -486,9 +477,9 @@ async function main(): Promise {
}
if (args.protocol) {
- if (args.protocol !== "acp" && args.protocol !== "claude" && args.protocol !== "codex") {
+ if (!PROTOCOLS.includes(args.protocol as Protocol)) {
console.error(`Unknown protocol: ${args.protocol}`);
- console.error("Available: acp, claude, codex");
+ console.error(`Available: ${PROTOCOLS.join(", ")}`);
process.exit(1);
}
filteredAgents = filteredAgents.filter((a) => a.protocol === args.protocol);
diff --git a/examples/feature-examples/src/scaffold.ts b/examples/feature-examples/src/scaffold.ts
index 3dac132..7d58325 100644
--- a/examples/feature-examples/src/scaffold.ts
+++ b/examples/feature-examples/src/scaffold.ts
@@ -2,6 +2,7 @@ import { RunloopSDK, type Secret } from "@runloop/api-client";
import { ACPAxonConnection, PROTOCOL_VERSION } from "@runloop/remote-agents-sdk/acp";
import { ClaudeAxonConnection } from "@runloop/remote-agents-sdk/claude";
import { CodexAxonConnection } from "@runloop/remote-agents-sdk/codex";
+import { PiAxonConnection } from "@runloop/remote-agents-sdk/pi";
import type { AgentConfig, AgentConfigOverride, BrokerMount, UseCase, RunContext } from "./types.js";
import { SkipError } from "./types.js";
import { withTimeout } from "./validator.js";
@@ -16,6 +17,14 @@ const SETUP_STEP_TIMEOUT_MS = 30_000;
const SETUP_ERROR_CLEANUP_TIMEOUT_MS = 10_000;
const DEVBOX_PROVISION_TIMEOUT_MS = 180_000; // 3 minutes for cold start with agent mounts
+// Pi resolves custom providers from ~/.pi/agent/models.json. `apiKey` keeps the
+// literal "$NEBIUS_API_KEY" because Pi interpolates $ENV_VAR itself, so the key
+// never lands in the file — only the env var name does. python3's json.dumps
+// handles any characters in the base URL safely; hand-assembling JSON in shell
+// would break on quotes and backslashes.
+const WRITE_PI_MODELS_CMD =
+ 'mkdir -p "$HOME/.pi/agent" && umask 077 && python3 -c \'import json, os; print(json.dumps({"providers": {"nebius": {"baseUrl": os.environ["NEBIUS_BASE_URL"], "api": "openai-completions", "apiKey": "$NEBIUS_API_KEY", "models": [{"id": "glm-5.2", "name": "GLM 5.2 (Nebius)", "reasoning": True, "contextWindow": 262144, "maxTokens": 32000}], "compat": {"thinkingFormat": "zai"}}}}))\' > "$HOME/.pi/agent/models.json"';
+
/**
* Provision a devbox with secrets, then initialize a connection.
*
@@ -94,6 +103,11 @@ export async function setup(agent: AgentConfig, useCase: UseCase): Promise "$HOME/.codex/auth.json"',
],
}),
+ // Pi reads the nebius/glm-5.2 provider from ~/.pi/agent/models.json,
+ // so materialize it before the broker spawns `pi`.
+ ...(mergedAgent.protocol === "pi" && {
+ launch_commands: [WRITE_PI_MODELS_CMD],
+ }),
},
},
{ longPoll: { timeoutMs: DEVBOX_PROVISION_TIMEOUT_MS } },
@@ -161,6 +175,7 @@ export async function setup(agent: AgentConfig, useCase: UseCase): Promise {
@@ -186,6 +201,32 @@ export async function setup(agent: AgentConfig, useCase: UseCase): Promise {
+ throw new SkipError(reason);
+ },
+ cleanup,
+ };
+
+ return { ctx, sdk };
+ }
+
+ if (mergedAgent.protocol === "pi") {
+ const conn = new PiAxonConnection(axon, devbox);
+
+ log("Connecting (Pi)...");
+ await withTimeout(conn.connect(), SETUP_STEP_TIMEOUT_MS, "Pi connect");
+
+ // Pi has no handshake — the broker issues `get_state` at spawn and after
+ // every turn, so there is deliberately no initialize() step here.
+ const ctx: RunContext = {
+ agent: mergedAgent,
+ acp: null,
+ claude: null,
+ codex: null,
+ pi: conn,
sessionId: null,
log,
skip: (reason: string) => {
@@ -210,6 +251,7 @@ export async function setup(agent: AgentConfig, useCase: UseCase): Promise {
@@ -238,6 +280,9 @@ export async function disconnect(ctx: RunContext): Promise {
} else if (ctx.codex) {
ctx.log("Disconnecting Codex...");
await ctx.codex.disconnect();
+ } else if (ctx.pi) {
+ ctx.log("Disconnecting Pi...");
+ await ctx.pi.disconnect();
}
}
@@ -274,6 +319,7 @@ function validateConfig(agent: AgentConfig): void {
acp: "acp",
claude: "claude_json",
codex: "codex_json",
+ pi: "pi_json",
} as const;
const expectedBrokerProtocol = brokerProtocolByClientProtocol[agent.protocol];
if (agent.brokerMount.protocol !== expectedBrokerProtocol) {
@@ -335,8 +381,8 @@ function buildBrokerMount(
return {
type: "broker_mount" as const,
axon_id: axonId,
- // The broker accepts protocol "codex_json", but the published
- // @runloop/api-client mount types don't include it yet.
+ // The broker accepts protocols "codex_json" and "pi_json", but the
+ // published @runloop/api-client mount types don't include either yet.
protocol: config.protocol as "acp" | "claude_json",
...(config.agentBinary && { agent_binary: config.agentBinary }),
...(config.launchArgs && { launch_args: config.launchArgs }),
diff --git a/examples/feature-examples/src/types.ts b/examples/feature-examples/src/types.ts
index de65822..b2d6532 100644
--- a/examples/feature-examples/src/types.ts
+++ b/examples/feature-examples/src/types.ts
@@ -1,6 +1,7 @@
import type { ACPAxonConnection } from "@runloop/remote-agents-sdk/acp";
import type { ClaudeAxonConnection } from "@runloop/remote-agents-sdk/claude";
import type { CodexAxonConnection } from "@runloop/remote-agents-sdk/codex";
+import type { PiAxonConnection } from "@runloop/remote-agents-sdk/pi";
import type { Client, Agent } from "@agentclientprotocol/sdk";
/**
@@ -20,8 +21,11 @@ export type InstallStrategy =
* Maps directly to the Runloop `broker_mount` API shape.
*/
export interface BrokerMount {
- /** Broker protocol: "acp" for ACP agents, "claude_json" for Claude Code, "codex_json" for native Codex. */
- protocol: "acp" | "claude_json" | "codex_json";
+ /**
+ * Broker protocol: "acp" for ACP agents, "claude_json" for Claude Code,
+ * "codex_json" for native Codex, "pi_json" for native Pi.
+ */
+ protocol: "acp" | "claude_json" | "codex_json" | "pi_json";
/** Path or name of the agent binary. */
agentBinary?: string;
/** CLI args passed to the agent binary. */
@@ -38,7 +42,7 @@ export interface AgentConfig {
name: string;
/** Which protocol this agent uses (client-side). */
- protocol: "acp" | "claude" | "codex";
+ protocol: "acp" | "claude" | "codex" | "pi";
/** How to install the agent on the devbox. */
install: InstallStrategy;
@@ -96,7 +100,7 @@ export interface UseCase {
description: string;
/** Which protocols this use case applies to. */
- protocols: Array<"acp" | "claude" | "codex">;
+ protocols: Array<"acp" | "claude" | "codex" | "pi">;
/** Per-use-case timeout in ms. Overrides the default. */
timeoutMs?: number;
@@ -145,16 +149,19 @@ export interface RunContext {
/** The agent config used for this run. */
agent: AgentConfig;
- /** ACP connection, or null if this is a Claude run. */
+ /** ACP connection, or null when another protocol is in use. */
acp: ACPAxonConnection | null;
- /** Claude connection, or null if this is an ACP or Codex run. */
+ /** Claude connection, or null when another protocol is in use. */
claude: ClaudeAxonConnection | null;
- /** Codex connection, or null if this is an ACP or Claude run. */
+ /** Codex connection, or null when another protocol is in use. */
codex: CodexAxonConnection | null;
- /** ACP session ID, or null for Claude/Codex (implicit session). */
+ /** Pi connection, or null when another protocol is in use. */
+ pi: PiAxonConnection | null;
+
+ /** ACP session ID, or null for Claude/Codex/Pi (implicit session). */
sessionId: string | null;
/** Log a message (appears in run output). */
@@ -181,7 +188,7 @@ export interface RunResult {
useCase: string;
/** Protocol used (e.g., "acp"). */
- protocol: "acp" | "claude" | "codex";
+ protocol: "acp" | "claude" | "codex" | "pi";
/** Outcome. */
status: "pass" | "fail" | "skip" | "xfail" | "xpass";
diff --git a/examples/feature-examples/src/use-cases/agent-via-blueprint.ts b/examples/feature-examples/src/use-cases/agent-via-blueprint.ts
index 28ab2a7..9c1e79a 100644
--- a/examples/feature-examples/src/use-cases/agent-via-blueprint.ts
+++ b/examples/feature-examples/src/use-cases/agent-via-blueprint.ts
@@ -38,6 +38,13 @@ const BLUEPRINT_OVERRIDES: Record = {
workingDirectory: "/home/user",
},
},
+ pi: {
+ install: { kind: "blueprint", blueprint: "axon-agents" },
+ brokerMount: {
+ agentBinary: "/usr/local/bin/pi",
+ workingDirectory: "/home/user",
+ },
+ },
"claude-code": {
install: { kind: "blueprint", blueprint: "axon-agents" },
brokerMount: {
@@ -50,7 +57,7 @@ const BLUEPRINT_OVERRIDES: Record = {
export default {
name: "agent-via-blueprint",
description: "Use pre-built blueprint with agents baked in",
- protocols: ["acp", "claude", "codex"],
+ protocols: ["acp", "claude", "codex", "pi"],
timeoutMs: 30_000,
provisionOverridesByAgent: BLUEPRINT_OVERRIDES,
diff --git a/examples/feature-examples/src/use-cases/index.ts b/examples/feature-examples/src/use-cases/index.ts
index 8ba82fc..c7c695d 100644
--- a/examples/feature-examples/src/use-cases/index.ts
+++ b/examples/feature-examples/src/use-cases/index.ts
@@ -3,6 +3,7 @@ import agentViaBlueprint from "./agent-via-blueprint.js";
import approvalCodex from "./approval-codex.js";
import elicitationAcp from "./elicitation-acp.js";
import elicitationClaude from "./elicitation-claude.js";
+import sessionResumePi from "./session-resume-pi.js";
import singlePrompt from "./single-prompt.js";
import threadResumeCodex from "./thread-resume-codex.js";
@@ -11,6 +12,7 @@ export const USE_CASES: UseCase[] = [
approvalCodex,
elicitationAcp,
elicitationClaude,
+ sessionResumePi,
singlePrompt,
threadResumeCodex,
];
diff --git a/examples/feature-examples/src/use-cases/session-resume-pi.ts b/examples/feature-examples/src/use-cases/session-resume-pi.ts
new file mode 100644
index 0000000..2db23ae
--- /dev/null
+++ b/examples/feature-examples/src/use-cases/session-resume-pi.ts
@@ -0,0 +1,86 @@
+import { isPiAssistantTextDeltaEvent } from "@runloop/remote-agents-sdk/pi";
+import type { UseCase } from "../types.js";
+
+const CODEWORD = "pineapple";
+const MEMORY_PROMPT = `Remember this codeword: "${CODEWORD}". Reply with just OK.`;
+const RECALL_PROMPT = "What is the codeword I asked you to remember? Reply with just the codeword.";
+
+/**
+ * Session resume: Pi persists each session as a transcript file on the devbox.
+ * `getState()` reports its path as `sessionFile` — start a session, converse on
+ * it, switch to a fresh session, then `switchSession()` back to the original
+ * file and verify its context is intact.
+ *
+ * (Broker-driven resume after a devbox restart needs nothing from the client:
+ * the broker replays the session file it owns under `--session-dir`. This
+ * demonstrates client-driven resume of a known session path.)
+ */
+export default {
+ name: "session-resume-pi",
+ description: "Resume a persisted session by file path and verify context is preserved",
+ protocols: ["pi"],
+ timeoutMs: 60_000,
+
+ async run(ctx) {
+ if (!ctx.pi) {
+ ctx.skip("Pi connection required");
+ return;
+ }
+ const pi = ctx.pi;
+
+ // Turn 1: establish context on the session the broker spawned Pi with.
+ const firstState = await pi.getState();
+ const firstSessionFile = firstState.sessionFile;
+ if (!firstSessionFile) {
+ throw new Error("get_state reported no sessionFile — cannot resume without one");
+ }
+ ctx.log(`Session A: ${firstState.sessionId} (${firstSessionFile})`);
+
+ ctx.log(`Sending memory prompt: "${MEMORY_PROMPT}"`);
+ await pi.send(MEMORY_PROMPT);
+ for await (const _frame of pi.receiveTurn()) {
+ // Drain until agent_settled.
+ }
+
+ // Switch to a fresh session — Pi now targets it, without the codeword.
+ await pi.newSession();
+ const secondState = await pi.getState();
+ ctx.log(`Session B: ${secondState.sessionId} (${secondState.sessionFile})`);
+ if (secondState.sessionId === firstState.sessionId) {
+ throw new Error("new_session did not create a new session");
+ }
+
+ // Resume the first session by its file path.
+ await pi.switchSession(firstSessionFile);
+ const resumedState = await pi.getState();
+ if (resumedState.sessionId !== firstState.sessionId) {
+ throw new Error(
+ `switch_session did not restore session A (got ${resumedState.sessionId}, want ${firstState.sessionId})`,
+ );
+ }
+ ctx.log(`Resumed session A: ${resumedState.sessionId}`);
+
+ // Turn 2 on the resumed session: the codeword must still be in context.
+ let responseText = "";
+ const unsubscribe = pi.onTimelineEvent((event) => {
+ if (isPiAssistantTextDeltaEvent(event)) {
+ responseText += event.data.assistantMessageEvent.delta;
+ }
+ });
+
+ ctx.log(`Sending recall prompt: "${RECALL_PROMPT}"`);
+ await pi.send(RECALL_PROMPT);
+ for await (const _frame of pi.receiveTurn()) {
+ // Drain until agent_settled.
+ }
+ unsubscribe();
+
+ if (!responseText.toLowerCase().includes(CODEWORD)) {
+ throw new Error(
+ `Resumed session lost context — expected "${CODEWORD}" in response, got: ${responseText}`,
+ );
+ }
+
+ ctx.log("Pass: Resumed session recalled the codeword from before the switch");
+ },
+} satisfies UseCase;
diff --git a/examples/feature-examples/src/use-cases/single-prompt.ts b/examples/feature-examples/src/use-cases/single-prompt.ts
index 3a86cca..f54a6b9 100644
--- a/examples/feature-examples/src/use-cases/single-prompt.ts
+++ b/examples/feature-examples/src/use-cases/single-prompt.ts
@@ -1,6 +1,7 @@
import { isAgentTextChunk } from "@runloop/remote-agents-sdk/acp";
import { isClaudeAssistantTextEvent, isClaudeResultEvent } from "@runloop/remote-agents-sdk/claude";
import { isCodexItemCompletedEvent } from "@runloop/remote-agents-sdk/codex";
+import { isPiAssistantTextDeltaEvent } from "@runloop/remote-agents-sdk/pi";
import type { UseCase } from "../types.js";
import { waitFor } from "../validator.js";
@@ -13,7 +14,7 @@ const ACP_CHUNK_WAIT_MS = 5_000;
export default {
name: "single-prompt",
description: "Send one prompt, receive text response",
- protocols: ["acp", "claude", "codex"],
+ protocols: ["acp", "claude", "codex", "pi"],
timeoutMs: 30_000,
async run(ctx) {
@@ -94,6 +95,30 @@ export default {
if (!responseText.trim()) throw new Error("Agent did not respond with any text");
+ ctx.log("Pass: Agent responded with text");
+ } else if (ctx.pi) {
+ ctx.log("Running Pi path...");
+
+ let responseText = "";
+ const unsub = ctx.pi.onTimelineEvent((event) => {
+ if (isPiAssistantTextDeltaEvent(event)) {
+ responseText += event.data.assistantMessageEvent.delta;
+ }
+ });
+
+ ctx.log(`Sending prompt: "${PROMPT}"`);
+ // send() resolves when Pi acknowledges the prompt, not when the turn
+ // finishes; a rejected prompt throws PiCommandError here.
+ await ctx.pi.send(PROMPT);
+
+ for await (const _frame of ctx.pi.receiveTurn()) {
+ // Drain until agent_settled. agent_end does not end the turn — Pi may
+ // auto-retry after it.
+ }
+ unsub();
+
+ if (!responseText.trim()) throw new Error("Agent did not respond with any text");
+
ctx.log("Pass: Agent responded with text");
} else {
ctx.skip("No connection available");
diff --git a/examples/feature-examples/templates/compatibility.md.template b/examples/feature-examples/templates/compatibility.md.template
index 32b6ee7..14f78df 100644
--- a/examples/feature-examples/templates/compatibility.md.template
+++ b/examples/feature-examples/templates/compatibility.md.template
@@ -4,8 +4,8 @@ SDK Version: {{sdkVersion}}
## Protocol × Feature
-| Use Case | ACP | Claude | Codex |
-|----------|-----|--------|-------|
+| Use Case | ACP | Claude | Codex | Pi |
+|----------|-----|--------|-------|----|
{{protocolFeatureRows}}
## ACP Agent × Feature
diff --git a/examples/feature-examples/templates/llms.txt.template b/examples/feature-examples/templates/llms.txt.template
index da5ddeb..d467047 100644
--- a/examples/feature-examples/templates/llms.txt.template
+++ b/examples/feature-examples/templates/llms.txt.template
@@ -8,7 +8,7 @@ Index for generating code that uses the SDK to connect to Runloop-hosted remote
npm install @runloop/remote-agents-sdk @runloop/api-client
# Claude module only:
npm install @anthropic-ai/claude-agent-sdk
-# Codex module needs no extra dependency (protocol types are vendored)
+# Codex and Pi modules need no extra dependency (protocol types are vendored)
```
## Connecting
@@ -26,7 +26,7 @@ https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-exampl
## Implementation Notes
-- Call `connect()` before `initialize()` (ACP/Claude; Codex has no initialize step).
+- Call `connect()` before `initialize()` (ACP/Claude/Codex; Pi has no initialize step).
- Use SDK type guards instead of raw Axon payloads.
- Prefer Agent Gateway for secrets; MCP Gateway for MCP endpoints; Network Policy for egress.
- Node >= 22 required.
diff --git a/llms.txt b/llms.txt
index abe99c9..9e3a497 100644
--- a/llms.txt
+++ b/llms.txt
@@ -8,7 +8,7 @@ Index for generating code that uses the SDK to connect to Runloop-hosted remote
npm install @runloop/remote-agents-sdk @runloop/api-client
# Claude module only:
npm install @anthropic-ai/claude-agent-sdk
-# Codex module needs no extra dependency (protocol types are vendored)
+# Codex and Pi modules need no extra dependency (protocol types are vendored)
```
## Connecting
@@ -17,11 +17,12 @@ Refer to ../scaffold.ts for setup and advice on best practices.
## Use Cases
-- [agent-via-blueprint](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/agent-via-blueprint.ts) — Use pre-built blueprint with agents baked in (acp + claude + codex)
+- [agent-via-blueprint](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/agent-via-blueprint.ts) — Use pre-built blueprint with agents baked in (acp + claude + codex + pi)
- [approval-codex](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/approval-codex.ts) — Handle a server-initiated command approval round-trip via onApprovalRequest (codex)
- [elicitation-acp](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/elicitation-acp.ts) — Handle agent-initiated user input via ACP session_elicitation (acp)
- [elicitation-claude](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/elicitation-claude.ts) — Handle agent-initiated user input via Claude conversational flow (claude)
-- [single-prompt](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/single-prompt.ts) — Send one prompt, receive text response (acp + claude + codex)
+- [session-resume-pi](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/session-resume-pi.ts) — Resume a persisted session by file path and verify context is preserved (pi)
+- [single-prompt](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/single-prompt.ts) — Send one prompt, receive text response (acp + claude + codex + pi)
- [thread-resume-codex](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/thread-resume-codex.ts) — Resume a server-side thread by id and verify context is preserved (codex)
## Compatibility
@@ -31,7 +32,7 @@ https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-exampl
## Implementation Notes
-- Call `connect()` before `initialize()` (required for ACP, Claude, and Codex).
+- Call `connect()` before `initialize()` (ACP/Claude/Codex; Pi has no initialize step).
- Use SDK type guards instead of raw Axon payloads.
- Prefer Agent Gateway for secrets; MCP Gateway for MCP endpoints; Network Policy for egress.
- Node >= 22 required.