24 · HTTP/SSE API Reference

pi-web exposes every session, configuration, attachment, state-bridge, and vision/AIGC operation as a uniform REST + SSE surface, driven under the hood by the framework-agnostic createPiWebHandler factory. This chapter is the consolidated endpoint reference for the whole manual — the endpoints scattered across the feature chapters (sessions list, message queue, attachments, extensions, AIGC/vision, Canvas) are gathered here. Read the relevant feature chapter first for the semantics, then come back here for the concrete request/response contracts.


Architecture Overview

Next.js has been deleted from main. The server host is Hono (server/index.ts); @hono/node-server acts only as a fetch↔Node adapter and introduces no framework-level abstraction. The entire /api/* surface collapses into one app.all('/api/*') forwarder to the getHandler() singleton — but before it, the host registers a few endpoints that bypass the handler (webext resources, /api/bootstrap), because the generic forwarder would otherwise match them first.

Browser (Vite SPA, dev :5173 / prod same port)
         │  fetch /api/**

server/index.ts  (Hono host, default PORT=3000)
  ├── app.get('/api/webext/singletons/:name')  ┐ registered first: webext resources
  ├── app.get('/api/webext/resolve')           │ (must precede the generic /api/*
  ├── app.get('/api/webext/dist/:dir/*')       │  forwarder, or the handler grabs the match)
  ├── app.get('/api/bootstrap')                ┘ SPA runtime configuration
  └── app.all('/api/*')  ─────────────┐  everything else under /api

                        getHandler()  ← lib/app/pi-handler.ts singleton


                        createPiWebHandler(opts)
                        packages/server/src/http/create-handler.ts
                                       ├── Router (method + path dispatch)
                                       ├── Built-in endpoints (sessions / config / attachments)
                                       └── Injected endpoints (config / attachment / agent-sources /
                                            favorites / session-list / aigc-models /
                                            vision-models / bash / extensions …)
  • c.req.raw is a standard Request; the Response returned by the handler (including an SSE ReadableStream body) is passed through verbatim — status/headers/body are not rewritten and nothing is buffered (server/index.ts:75-91).
  • The host is a long-running process: it spawns session subprocesses and holds long-lived SSE connections, so it cannot run on Edge/stateless Serverless. This is a framework-agnostic runtime constraint, no longer enforced by any runtime="nodejs" declaration.
  • The server is bundled by esbuild into a single file dist/server.mjs (the entry must sit at the artifact root — see 19 · Deployment & Operations).

Port: default PORT=3000 (server/index.ts:100, process.env.PORT ?? 3000). In development, pnpm dev concurrently brings up the API (:3000) and Vite dev (:5173); the browser opens 5173 and /api is proxied by Vite to 3000; hitting the API directly (as in this chapter’s curl examples) targets 3000. Production is a single process on one port.

Endpoint quick reference (grouped by purpose; see the corresponding sections for detail):

PurposeEndpoint
SPA bootstrapGET /bootstrap (runtime config, mounted directly by the host, bypasses the handler)
Session lifecyclePOST /sessions, DELETE /sessions/:id
Sessions listGET /sessions (lists historical sessions, paginated)
Agent-source enumerationGET /agent-sources, GET·PUT /agent-sources/favorites
Event subscriptionGET /sessions/:id/stream (SSE)
Send message / steerPOST /sessions/:id/messages, /steer, /follow_up, /abort
Session controlPOST /sessions/:id/models, /thinking, /fork, /ui-response, /ui-rpc
State-injection bridgePOST /sessions/:id/state (write-back; downstream via SSE control:state frame)
Session queriesGET /sessions/:id/state, /stats, /messages, /commands, /models, /fork-messages, /completion
Agent-declared routesGET /sessions/:id/agent-routes, GET·POST /sessions/:id/agent-routes/:name
ConfigurationGET·PUT /config/:domain, GET /config/models
Model enumeration (by type)GET /config/models?input=&output=
AttachmentsPOST /sessions/:id/attachments, GET /attachments/:id/raw

The full prefix for every endpoint on the browser/curl side is /api/** (the handler’s internal routes carry no /api; that is aligned by sse.basePath). webext and /bootstrap are mounted directly by the host; everything else goes through app.all('/api/*') → handler.


Common Conventions

Response Structure

A successful response returns a JSON object, with the HTTP status code depending on the endpoint (see below). All responses (both success and error) carry a protocol-version response header and response-body field (the current protocol version is 0.1.0, defined in packages/protocol/src/version.ts):

X-Pi-Protocol-Version: 0.1.0

Successful response bodies also have a protocolVersion field injected (uniformly appended by jsonResponse).

Error responses use a uniform structure:

{
  "error": {
    "code": "SESSION_NOT_FOUND",
    "message": "Session \"abc\" not found.",
    "fields": ["source"]
  },
  "protocolVersion": "0.1.0"
}

fields appears only when request-body validation fails (400); its value is a list of the offending field paths.

Error Code Mapping

ScenarioHTTP statuscode
SessionNotFoundError / :id not found404SESSION_NOT_FOUND
SessionStoppedError409SESSION_STOPPED
UnknownExtensionUIError409UNKNOWN_EXTENSION_UI
MissingInputError400MISSING_INPUT
body is not JSON400INVALID_JSON
body DTO validation failed400VALIDATION_FAILED (with fields)
shutting down (no longer accepting new sessions)503SHUTTING_DOWN
upstream RPC command failed502UPSTREAM_ERROR
no path match404NOT_FOUND
path matched but method mismatched405METHOD_NOT_ALLOWED
unknown exception500INTERNAL

Source of the code literals: the session-engine error codes are in packages/server/src/session/session.errors.ts:7 (SESSION_STOPPED / SESSION_NOT_FOUND / UNKNOWN_EXTENSION_UI / MISSING_INPUT); the HTTP-layer codes are in packages/server/src/http/error-map.ts and the individual route handlers.

Version incompatibility (the client declares an X-Pi-Protocol-Version whose major version does not match the server’s 0; if not declared, the request is allowed through): → 426 PROTOCOL_VERSION_MISMATCH

Auth seam (allows through by default): → authResolver rejects: 401 UNAUTHORIZED; authorizeSession returns false: 403 FORBIDDEN


Bootstrap API — /api/bootstrap

GET /api/bootstrap — SPA Runtime Configuration

On startup the SPA fetches this endpoint first, retrieving the runtime configuration needed to render the source picker / session page. It is mounted directly by the host (server/index.ts:67, bypassing createPiWebHandler) and replaces the two injection points of the Next.js era: the server component’s props, and the 15 NEXT_PUBLIC_* gates — the latter were inlined at build time under Next (so setting those envs at CLI runtime had no effect). Now folded into this endpoint, they become true runtime configuration, so runtime switches like pi-web --canvas actually take effect.

Query parameters:

ParameterDescription
sessionIdOptional. Pass it when cold-loading /session/:id; the response then carries that session’s agent-source recovery result (resumeSource), otherwise the webext extension surface silently disappears after a refresh

Success response 200 (BootstrapPayload, see server/bootstrap.ts:28):

{
  "defaultCwd": "/workspace",
  "autoStart": false,
  "multiTenant": false,
  "hostApiVersion": "0.1.0",
  "defaultSource": "/path/to/agent",   // optional (when a default source is configured)
  "defaultModel": "claude-opus-4-5",   // optional
  "resumeSource": "/path/to/agent",    // optional (when ?sessionId= matches and can be recovered)
  "features": {
    "canvas": false,          // NEXT_PUBLIC_PI_WEB_CANVAS
    "sourcePicker": false,    // NEXT_PUBLIC_PI_WEB_SOURCE_PICKER
    "launcherRail": false,    // NEXT_PUBLIC_PI_WEB_LAUNCHER_RAIL
    "bashEnabled": false,     // NEXT_PUBLIC_PI_WEB_BASH_ENABLED
    "sessionsGlobal": false,  // NEXT_PUBLIC_PI_WEB_SESSIONS_GLOBAL
    "sessionsManage": true,   // off only when explicitly false/0
    "sessionsSlot": "sidebar",
    "extensionCommands": "",
    "extensionAllowlist": "",
    "extensionBaseUrl": "",
    "disableReadinessHandshake": false
  }
}

Provider secrets never appear in the response. The supabase field is emitted only when PI_WEB_MULTI_TENANT=1 and a URL/anon key are configured (the anon key is already a public, browser-side key). For each gate env see 06 · Configuration and 14 · Sessions List.

Implementation reference: server/bootstrap.ts


Sessions API — /api/sessions/**

POST /api/sessions — Create a Session

Establishes a new agent session and returns a server-generated sessionId (driven by the main process’s randomUUID(), then passed down to the agent to align with the persistence file id).

Request body (CreateSessionRequestSchema, see packages/protocol/src/transport/rest-dto.ts:38):

{
  "source": "/path/to/agent",
  "cwd": "/working/dir",
  "model": "claude-opus-4-5",
  "env": { "MY_VAR": "value" }
}
FieldTypeRequiredDescription
sourcestringYesagent source path or identifier
cwdstringNoworking directory
modelstringNooverride the default model
envobject (string→string)Noadditional environment variables
trustbooleanNoexplicit project-trust intent; gates loading of .pi/ extensions/subagents/skills; when omitted, decided by the server’s trust policy
resumeIdstringNowhen given, “resume an existing session” rather than create a new one; the server resumes from persisted metadata; when absent, a new session is created

Success response 201:

{ "sessionId": "550e8400-e29b-41d4-a716-446655440000", "protocolVersion": "0.1.0" }

sessionId is a UUID (the sess_abc used in other endpoints is merely a placeholder).

Errors: 400 (missing source or DTO validation failure), 503 (service shutting down)

curl example:

curl -X POST http://localhost:3000/api/sessions \
  -H "Content-Type: application/json" \
  -d '{"source": "/path/to/.pi", "cwd": "/workspace"}'

GET /api/sessions — List Historical Sessions

Lists locally persisted historical sessions (only lightweight session-header metadata; the body is not read), used for browsing and resuming in the Sessions List panel. Mounted via the routes: injection seam (createSessionListRoutes()), coexisting with the built-in sessions endpoints. Sorted by updatedAt ?? createdAt descending, with keyset cursor pagination.

Query parameters (ListSessionsRequestSchema, see packages/protocol/src/transport/rest-dto.ts:187):

ParameterTypeRequiredDescription
scope"cwd" | "all"Nodefaults to cwd (the current directory); all (system/whole-machine) is subject to a global gate
cwdstringNothe target directory for scope=cwd (fallback when sessionId is unavailable)
sessionIdstringNowhen scope=cwd, prefer this session’s persisted cwd as the target directory
limitpositive integerNoper-page cap, defaults to 50, hard-clamped to 200
cursorstringNoopaque keyset cursor (base64url(JSON.stringify({ ts, id }))), to fetch the next page
qstringNoname search keyword (sidebar-launcher-rail): when non-empty, filters by session name/id substring (case-insensitive), applied before sort/pagination; absent/empty keeps the existing behavior (backward compatible). Max length 100. Matches names only, not body content

Success response 200 (ListSessionsResponse, see rest-dto.ts:222):

{
  "sessions": [
    {
      "sessionId": "550e8400-...",
      "name": "Refactor auth module",   // optional
      "cwd": "/workspace",
      "createdAt": "2025-06-01T08:00:00.000Z",
      "updatedAt": "2025-06-01T09:30:00.000Z"  // optional (some storage backends lack this value)
    }
  ],
  "nextCursor": "eyJ0cyI6...",  // absent means no more pages
  "scope": "cwd",                // echoes back the effective scope
  "globalEnabled": true,         // whether the system view is enabled, so the frontend can confirm entry-point availability
  "protocolVersion": "0.1.0"
}

Errors:

StatuscodeTrigger
400INVALID_REQUESTscope / limit / cursor invalid (the response includes the offending fields)
403SESSIONS_GLOBAL_DISABLEDscope=all but the system view is not enabled (storage is not touched; no session data is returned)
500INTERNALstorage read exception
curl "http://localhost:3000/api/sessions?scope=cwd&limit=50"

The system view (scope=all) is off by default and requires the deployer to set NEXT_PUBLIC_PI_WEB_SESSIONS_GLOBAL=true. For the full mechanism of pagination, gating, the frontend’s three states, and relocation, see 14 · Sessions List.

Implementation reference: packages/server/src/session-list/session-list-routes.ts

GET /api/agent-sources — List Available Agent Sources

Read-only enumeration of “agent sources available in the current environment,” for the new-session picker (AgentSourcePicker) to browse and pick — clicking an item creates a session with its source directly (equivalent to typing it). Data comes from two merged channels: a directory scan (first-level subdirectories under PI_WEB_SOURCES_ROOT, reusing source-probe semantics to classify custom/cli) ∪ a registry file (PI_WEB_SOURCES_REGISTRY JSON), deduplicated by id (registry overrides scan). Mounted via the routes: injection seam (createAgentSourcesRoutes()).

Strictly read-only: handling a request performs no writes, no git clone, and no resolve/spawn of a session subprocess. Returns an empty list (success) when no source is configured.

Query parameters (ListAgentSourcesRequestSchema, see packages/protocol/src/transport/rest-dto.ts):

ParameterTypeRequiredDescription
limitpositive integerNopage size, default 100, hard-clamped to 500
cursorstringNoopaque keyset cursor (base64url(JSON.stringify({ id }))), to fetch the next page

Success response 200 (ListAgentSourcesResponse):

{
  "sources": [
    {
      "id": "/abs/examples/hello-agent",   // stable id: dir→realpath; git→url@ref
      "source": "/abs/examples/hello-agent", // source string passed to POST /sessions
      "name": "hello-agent",                // technical name: package.json name > dir/repo basename
      "kind": "dir",                        // "dir" | "git"
      "origin": "scan",                     // "scan" | "registry"
      "mode": "custom",                     // "custom" (has entry) | "cli"
      "title": "Hello Agent",               // optional display title (pi-web.title / registry.title); list uses title ?? name
      "description": "…",                   // optional (pi-web.description / registry.description / package.json description)
      "avatar": "🤖"                        // optional avatar: image URL/data-URI → <img>; else short text/emoji; falls back to the title's initial
    }
  ],
  "nextCursor": "eyJpZCI6...",  // absent means no more pages
  "protocolVersion": "0.1.0"
}

Display-metadata source: scanned sources read title / description / avatar from their package.json pi-web field (the same place as pi-web.entry); name still comes from the top-level package.json name, and description falls back to the top-level one. Registry entries may declare title / description / avatar directly. The frontend renders the source list as a widescreen card grid: each card has an avatar + title ?? name + mode badge + description + favorite star.

// package.json fragment of an example source
{
  "name": "hello-agent",
  "pi-web": {
    "entry": "index.ts",
    "title": "Hello Agent",
    "description": "The simplest echo agent, for a first walkthrough",
    "avatar": "🤖"
  }
}

Errors:

StatuscodeTrigger
400INVALID_REQUESTlimit / cursor invalid (the response includes the offending field)
500INTERNALunexpected assembly/serialization failure (missing/corrupt sources do not count — they degrade to an empty contribution)
curl "http://localhost:3000/api/agent-sources?limit=100"

Whether the frontend shows the source list is gated by NEXT_PUBLIC_PI_WEB_SOURCE_PICKER=1 — now delivered at runtime via features.sourcePicker after GET /api/bootstrap reads the env server-side (no longer inlined at build time as in the Next.js era). The backend sources are configured via PI_WEB_SOURCES_ROOT (path.delimiter-separated for multiple) and PI_WEB_SOURCES_REGISTRY (default <agentDir>/sources.json). See 06 · Configuration for all three.

Implementation reference: packages/server/src/agent-source-list/

GET·PUT /api/agent-sources/favorites — Agent Source Favorites (Read/Write)

Favorites are a user preference (sidebar-launcher-rail), independent of the read-only source enumeration /agent-sources; persisted at <agentDir>/agent-source-favorites.json, used by the sidebar launcher rail to render one-click launch anchors. Injected via createFavoritesRoutes() and mounted at /agent-sources/favorites (GET+PUT). Favoriting/unfavoriting does not modify the enumeration sources (scan dir / registry).

  • GETListFavoritesResponse: { "favorites": [ { "source": "...", "name": "..." } ] }. A missing/corrupt file degrades to the remaining available items.
  • PUT { favorites }ListFavoritesResponse (echoes the persisted result): full replace (idempotent), atomic tmp+rename write. Invalid body → 400 INVALID_REQUEST.
curl -X PUT "http://localhost:3000/api/agent-sources/favorites" \
  -H "Content-Type: application/json" \
  -d '{"favorites":[{"source":"./examples/hello-agent","name":"hello-agent"}]}'
StatuscodeTrigger
400INVALID_REQUESTPUT body invalid JSON / shape mismatch
500INTERNALread/write preference file error

Implementation reference: packages/server/src/agent-source-list/favorites-store.ts, favorites-routes.ts


GET /api/sessions/:id/stream — SSE Event Stream

Establishes a long-lived connection to receive session events in real time (text deltas, tool calls, control frames, etc.). This subscription is established per turn: each turn’s reply is carried by a fresh /stream connection the client opens for that turn — it is not a single session-level persistent connection, and having no stream while idle is normal.

Call-ordering convention (important): the client must first create the session, open this turn’s /stream subscription first, and only then POST /messages to submit the prompt — the turn’s reply frames come back over that already-established stream. The order must not be reversed: reply frames are broadcast transiently via the server’s EventEmitter with no buffering, so if the stream is not yet connected, frames broadcast before the connection window are lost permanently (see “Race-condition note” below).

Response headers:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
Content-Encoding: identity
X-Pi-Protocol-Version: <semver>

SSE frame format:

event: uiMessageChunk
id: 42
data: {"kind":"uiMessageChunk","protocolVersion":"0.1.0","chunk":{"type":"text-delta","id":"t1","delta":"Hello"}}

event: control
id: 43
data: {"kind":"control","protocolVersion":"0.1.0","payload":{"control":"error","message":"session ended: stopped","code":"stopped"}}

: keep-alive
  • The event: line = frame kind (uiMessageChunk or control, i.e. the frame’s kind field)
  • The id: line = a monotonic frame sequence number, to be carried as Last-Event-ID on reconnect
  • Heartbeat frames (: keep-alive) are sent every 15 seconds (DEFAULT_HEARTBEAT_MS = 15_000) to prevent proxy timeouts
  • The control-frame payload lives in the payload field and is discriminated by payload.control (not type); when the session ends, the server sends one frame with payload.control = "error" (message describes the reason, code is the end reason) and then closes the connection

Reconnection and replay boundary: GET this endpoint again with a Last-Event-ID header; the server re-subscribes and continues pushing subsequent frames. Last-Event-ID serves only as the starting sequence number (startSeq) for continued delivery — the gateway does not buffer historical frames and does not replay historical message frames by sequence number. All a late subscriber (including a reconnection) can “recover” is: the log ring-buffer, plus the sticky frames session-status / session-state / queue / state-bridge state (registered per key) (packages/server/src/session/pi-session.ts:271,396,436,578,640); the uiMessageChunk reply frames already broadcast for the current turn are not replayed — they are broadcast transiently via the EventEmitter with no buffering. To retrieve missed reply content, use the history endpoint GET /sessions/:id/messages.

curl -N "http://localhost:3000/api/sessions/sess_abc/stream" \
  -H "Last-Event-ID: 42"

Errors: 404 (session not found), 409 SESSION_ENDED (session already ended; returns an explicit response rather than hanging on an empty stream)

Important: session stats (usage statistics) are not pushed over a dedicated control:"stats" frame. Although the SSE control-frame schema defines a stats type, pi-session never actually sends a payload.control = "stats" frame; usage data must be actively pulled via the GET /sessions/:id/stats REST endpoint (or read from the sticky control:"session-state" snapshot frame’s snapshot.stats). For the control frames actually emitted, see SSE Frame Reference below.

Race-condition note: POST /messages can trigger the agent’s first frame extremely fast (measured ~32ms), whereas the same turn’s /stream may take several seconds to connect under a dev cold compile or heavy load (measured ~3237ms cold, ~79ms warm). If the POST happens before the stream connects, the uiMessageChunk frames broadcast before the connection window are lost permanently because the server does not buffer them, and the turn’s reply becomes visible only after a refresh (via the GET /sessions/:id/messages history endpoint) — appearing as “you must manually refresh to see the reply after sending a message,” and intermittently so, since it depends on whether the stream connects ahead of the agent’s first frame. To avoid it, strictly follow the call-ordering convention above: open this turn’s /stream subscription first, then POST /messages.


POST /api/sessions/:id/messages — Send a Message

Sends a user message to the session, triggering agent inference. Inference results are pushed asynchronously via this turn’s already-established /stream connection. You must open this turn’s /stream subscription before calling this endpoint: reply frames are broadcast transiently via the server’s EventEmitter with no buffering, so if this endpoint runs before /stream connects, the reply frames before the connection window are lost permanently and can only be recovered by refreshing via the GET /sessions/:id/messages history endpoint (see the “Race-condition note” under GET /sessions/:id/stream).

Request body (PromptRequestSchema, see packages/protocol/src/transport/rest-dto.ts:67):

{
  "message": "Please help me analyze this code",
  "images": [],
  "attachmentIds": ["att_xyz789"],
  "streamingBehavior": "steer"
}
FieldTypeRequiredDescription
messagestringYesuser message text (note the field name is message, not prompt)
imagesarrayNovision image content (base64)
attachmentIdsstring[]Nopublic ids of already-persisted attachments (att_<nanoid>); the server injects structured text references
streamingBehavior"steer" | "followUp"Nobehavior when submitting while inference is in progress

Success response 200: { "ok": true } (the message has been forwarded to the agent)

Errors: 400 (validation failure), 404 (session not found), 409 (session stopped)

curl -X POST http://localhost:3000/api/sessions/sess_abc/messages \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello, agent!"}'

POST /api/sessions/:id/steer — Steer Output

Injects steering text while inference is in progress.

Request body (SteerRequestSchema): { "message": "Please answer in Chinese", "images": [] } (images is optional; the field name is message, not text)

Success response 200: { "ok": true } Errors: 400, 404, 409


POST /api/sessions/:id/follow_up — Follow-up

Request body: same structure as steer (SteerRequestSchema): { "message": "Continue" }

Success response 200: { "ok": true } Errors: 400, 404, 409


POST /api/sessions/:id/abort — Abort Inference

Aborts the current in-progress inference round.

Request body: none (empty body)

Success response 200: { "ok": true }

Errors: 404, 409


POST /api/sessions/:id/models — Switch Model

Changed (spec multi-gateway-providers, task 6.5): the old path POST /api/sessions/:id/model (singular) is deprecated and now always returns 410 ENDPOINT_MOVED with the new path in the message — it does not 404 silently, so existing integrations can tell an interface change from a missing session.

The new path shares its URL with the query endpoint GET /api/sessions/:id/models, differing only by method: GET lists the models available to that session, POST sets the current one.

Request body (SetModelRequestSchema): { "provider": "anthropic", "modelId": "claude-sonnet-4-5" } (both fields are required; note it is provider + modelId, not a single model field)

Success response 200: { "ok": true } Errors: 400, 404, 409


POST /api/sessions/:id/thinking — Set Extended Thinking

Request body (SetThinkingRequestSchema): { "level": "high" }

level takes a value from the ThinkingLevel enum: "minimal" | "low" | "medium" | "high" | "xhigh" (see packages/protocol/src/rpc/model.ts:19). There are no enabled / budget fields.

Success response 200: { "ok": true } Errors: 400, 404, 409


POST /api/sessions/:id/ui-response — Extension UI Response

Returns the response a user produced in an extension UI interaction back to the agent. The request body is pi’s RpcExtensionUIResponse (aliased as UiResponseRequestSchema, see rest-dto.ts:118), whose id field identifies the corresponding UI request.

Success response 200: { "ok": true } Errors: 400 (validation failure), 404 (session not found), 409 (unknown UI request id, or session stopped)


POST /api/sessions/:id/ui-rpc — Tier3 UI↔agent RPC

The upstream RPC request from a Web UI extension (Tier3) (UiRpcRequestSchema). The response is not returned at this endpoint; instead it flows back via an SSE control frame (payload.control = "ui-rpc"), paired by correlationId.

Success response 200: { "ok": true } Errors: 400, 404, 409


POST /api/sessions/:id/state — Write Back Session Shared State (State-Injection Bridge)

The state-injection bridge is a session-level shared-KV route separate from the LLM conversation history, with the authoritative state living in the agent subprocess. This endpoint is its UI→agent write-back direction: validate StateSetRequestPiSession.setState (dispatched to the subprocess via an internal stdin line) → 200 synchronous ack. The frontend converges via the downstream SSE control:"state" frame (not awaited at this endpoint) — see SSE Frame Reference below. For the author-side read/write API (getSessionState()), see 04 · Surface Authoritative Surface Stack and 08 · Custom Agent Development.

Request body (StateSetRequestSchema, see packages/protocol/src/web-ext/state.ts:27):

{ "key": "aigc.selectedModel", "value": "gemini-3.1-flash-image", "op": "set" }
FieldTypeRequiredDescription
keystringYesstate key (non-empty)
valueany JSON-serializable valueNothe new value when op=set; transport-agnostic, not limited to text
op"set" | "delete"Nodefaults to set; value is ignored when delete

Success response 200: { "ok": true } (synchronous ack; the actual state change flows back via the control:state frame) Errors: 400 (payload violates the contract, authoritative state unchanged), 404 (session not found)

curl -X POST http://localhost:3000/api/sessions/sess_abc/state \
  -H "Content-Type: application/json" \
  -d '{"key":"aigc.selectedModel","value":"gemini-3.1-flash-image"}'

Implementation reference: packages/server/src/http/routes/state-routes.ts:25


POST /api/sessions/:id/fork — Fork a Session

Forks from a specified history entry. Request body (ForkRequestSchema): { "entryId": "..." }

Success response 200: { "text"?: string, "cancelled"?: boolean } Errors: 400, 404, 409, 502 (upstream command failed)


GET /api/sessions/:id/state — Query Session State

Success response 200 (state is RpcSessionState, see session-state.ts:18):

{
  "state": {
    "sessionId": "550e8400-...",
    "thinkingLevel": "high",
    "isStreaming": false,
    "isCompacting": false,
    "steeringMode": "...",
    "followUpMode": "...",
    "autoCompactionEnabled": true,
    "messageCount": 12,
    "pendingMessageCount": 0,
    "model": { "...": "..." }
  },
  "protocolVersion": "0.1.0"
}

Errors: 404, 502 (upstream command failed)


GET /api/sessions/:id/stats — Query Usage Statistics

Note: stats data is pulled only via this endpoint; the SSE stream does not push usage frames.

Success response 200 (stats is SessionStats, see session-state.ts:54):

{
  "stats": {
    "sessionId": "550e8400-...",
    "userMessages": 6,
    "assistantMessages": 6,
    "toolCalls": 5,
    "toolResults": 5,
    "totalMessages": 12,
    "tokens": { "input": 3200, "output": 800, "cacheRead": 0, "cacheWrite": 0, "total": 4000 },
    "cost": 0.0042
  },
  "protocolVersion": "0.1.0"
}

Errors: 404, 502 (upstream command failed)

curl http://localhost:3000/api/sessions/sess_abc/stats

GET /api/sessions/:id/messages — Query Message History

Success response 200: { "messages": [...] } Errors: 404, 502


GET /api/sessions/:id/commands — Query Available Commands

Returns the list of commands currently available to the session (a pure query, with no install/trust semantics).

Success response 200: { "commands": [...] } Errors: 404, 502


GET /api/sessions/:id/models — Query Available Models

Returns the list of models available to the session’s agent ({ models: Model[] }, with elements in pi’s Model shape), filtered by the PI_WEB_HIDE_PROVIDERS environment variable (removes models of hidden providers; uses the same list as the settings page’s /config/models).

Success response 200: { "models": [...] } Errors: 404, 502


GET /api/sessions/:id/fork-messages — Query Forkable Entries

Returns the list of history entries that can serve as fork starting points.

Success response 200: { "messages": [{ "entryId": "...", "text": "..." }] } Errors: 404, 502


GET /api/sessions/:id/completion — Trigger Completion

The query endpoint of the trigger-completion framework (e.g. @file: to reference a file). Paired with GET /api/sessions/:id/completion/triggers, which returns the registered triggers. See 02 · Core Concepts.

Success response 200: completion result JSON Errors: 404


GET /api/sessions/:id/agent-routes — Agent-Declared Route Listing

The HTTP routes an agent declares in AgentDefinition.routes (for the declaration-side contract, see 08 · Custom Agent Development Guide) are automatically mounted under the session namespace when the session is created. This endpoint returns the route listing declared by that session — a pure-data projection (name / methods / description); the handler functions live only in the agent subprocess and never cross the process boundary.

Success response 200 (an agent with no declarations returns an empty array — that’s success, not an error):

{
  "routes": [
    {
      "name": "gallery-stats",
      "methods": ["GET"],
      "description": "Canvas gallery statistics (asset counts / origin breakdown / generating flag)"
    }
  ],
  "protocolVersion": "0.1.0"
}

Errors: 404 (session not found), 401/403 (rejected by the existing :id auth seam). When operationally disabled (PI_WEB_AGENT_ROUTES_DISABLED=1, see the env table below), the endpoint returns a generic 404 NOT_FOUND without revealing its existence.

curl -s http://localhost:3000/api/sessions/sess_abc/agent-routes

GET·POST /api/sessions/:id/agent-routes/:name — Invoke a Declared Route

Forwards one HTTP call into the session’s agent subprocess, where the handler bound by the declaration processes it, and returns the result synchronously within the same HTTP request-response cycle — external systems (curl / webhooks / third-party services) can invoke agent capabilities without subscribing to any SSE stream. Under the hood this rides the existing stdin/stdout JSONL channel with a declaration frame plus a dedicated request/result frame pair; no new SSE frames are added.

Invocation semantics:

  • The handler executes only inside the agent subprocess; an invocation does not trigger LLM inference, does not enter the conversation history, and produces no UI change whatsoever.
  • Calls are accepted as usual while the session is busy (mid-inference), without interfering with the conversation.
  • GET invocations ignore the request body (it is not read); an empty POST body is leniently allowed (body is passed to the handler as undefined), while a non-empty body that is not valid JSON → 400.
  • The success response body is the raw JSON returned by the handler (object, array, or scalar; undefined is normalized to null), with no protocolVersion envelope; the protocol version is carried only via the X-Pi-Protocol-Version response header.

Errors (check order: gate → session/auth → name → method → size → JSON → forwarding):

StatuscodeTrigger
404NOT_FOUNDoperationally disabled via PI_WEB_AGENT_ROUTES_DISABLED=1 (does not reveal endpoint existence)
404SESSION_NOT_FOUNDsession not found
401 / 403UNAUTHORIZED / FORBIDDENrejected by the existing :id auth seam
404ROUTE_NOT_FOUNDroute name not declared by this session’s agent definition
405METHOD_NOT_ALLOWEDmethod not in the route’s declared methods allowlist (defaults to ["GET"])
413PAYLOAD_TOO_LARGEPOST body exceeds the limit (default 1 MiB; rejected early via the Content-Length header, with a fallback re-check against actual bytes after reading when the header is missing/untrusted)
400INVALID_BODYnon-empty POST body is not valid JSON
502ROUTE_HANDLER_ERRORhandler threw an error (the error message carries the handler-side message)
504ROUTE_TIMEOUTsubprocess response timed out (default 20000 ms)
409SESSION_STOPPEDsession already stopped

Environment variables:

envDefaultDescription
PI_WEB_AGENT_ROUTES_DISABLEDunset (feature enabled)=1 — server-authoritative kill switch; all agent-routes endpoints return a generic 404; read per request
PI_WEB_AGENT_ROUTE_TIMEOUT_MS20000response timeout (ms) for forwarding into the subprocess; timeout → 504
PI_WEB_AGENT_ROUTE_BODY_LIMIT1048576 (1 MiB)POST request-body limit in bytes; exceeded → 413
# Invoke (GET; query parameters are flattened to single values and passed to the handler)
curl -s "http://localhost:3000/api/sessions/sess_abc/agent-routes/gallery-stats?verbose=1"
 
# Invoke (POST; the route must declare "POST" in its methods)
curl -s -X POST http://localhost:3000/api/sessions/sess_abc/agent-routes/my-route \
  -H "Content-Type: application/json" \
  -d '{"key": "value"}'

For a runnable demo, see examples/aigc-canvas-agent (the “Agent Routes demo (gallery-stats)” section of its README: a read-only stats route invoked directly with curl, returning structured JSON). For the declaration-side contract (name format, default methods, handler constraints, assembly-time validation), see 08 · Custom Agent Development Guide.

Implementation reference: packages/server/src/http/routes/agent-route-routes.ts


DELETE /api/sessions/:id — Delete a Session

Stops and removes the session. After the handler returns, the host layer (the DELETE branch of app.all in server/index.ts:80-88), when the response is res.ok, additionally calls forgetSessionSource(id) to clear the app-level sessionId → source mapping (best-effort, without rewriting the handler response; prevents unbounded growth of the mapping table). Deletion of sub-resources (extra path segments) does not trigger it.

Success response 200: { "ok": true } Errors: 404

curl -X DELETE http://localhost:3000/api/sessions/sess_abc

Config API — /api/config/**

Read/write interface for configuration domains. There are five known domains: auth, settings, sandbox, logging, aigc (packages/server/src/config/config-routes.ts:30); models is a special endpoint. For the schema-driven settings UI, see 13 · Config UI.

GET /api/config/:domain — Read Configuration

Path parameter: domain = auth | settings | sandbox | logging | aigc

Success response 200:

{
  "formSchema": { "...": "..." },
  "values": { "apiKey": "sk-***", "model": "claude-opus-4-5" },
  "protocolVersion": "0.1.0"
}

Secret fields in values return a masked value (sk-***); plaintext is not returned.

Errors: 404 DOMAIN_NOT_FOUND (unknown domain), 401 UNAUTHORIZED / 403 FORBIDDEN (admin auth seam rejected)


PUT /api/config/:domain — Write Configuration

Request body:

{ "values": { "apiKey": "sk-new-key", "model": "claude-opus-4-5" } }

A masked value (sk-***) is automatically merged back to the on-disk original value on write (it does not overwrite unchanged secrets).

Success response 200: { "ok": true } Errors: 400 INVALID_JSON (JSON parse failure) / VALIDATION_FAILED (DTO validation failure), 422 SCHEMA_VALIDATION_FAILED (domain schema validation failure, with fields), 404 DOMAIN_NOT_FOUND, 401/403


GET /api/config/models — List Available Models (Config Side)

Provides data for the settings page’s provider/model dropdown controls. Filtered by the PI_WEB_HIDE_PROVIDERS environment variable (a comma-separated list of provider names, case-sensitive).

Success response 200:

{
  "providers": ["anthropic", "openai"],
  "models": [
    { "id": "claude-opus-4-5", "provider": "anthropic" },
    { "id": "gpt-4o", "provider": "openai" }
  ]
}

When the listModelOptions seam is not configured, returns { "providers": [], "models": [] }, and the frontend falls back to free-text input.

When PI_WEB_HIDE_PROVIDERS=anthropic, all of anthropic’s providers and models are removed from the result. This filter uses the same list as the chat area’s GET /sessions/:id/models.

Implementation reference: packages/server/src/config/config-routes.ts, packages/server/src/config/model-options-filter.ts


Model Enumeration API — Tool-Side Model Enumeration

Changed (spec multi-gateway-providers, task 4.3): GET /api/aigc/models and GET /api/vision/models have been removed. Their capability is covered by type filtering on the unified catalog endpoint GET /api/config/models — the model catalog is no longer split into per-purpose endpoints; it is one list queried by input/output type.

Filtering models by type (replaces both endpoints above)

GET /api/config/models accepts input / output query parameters over the value domain text / image / video / audio. Entries use unified field names: { provider, id, name, input, output, source }.

Old endpointEquivalent queryMeaning
GET /api/aigc/modelsGET /api/config/models?output=imageImage generation models (produce images)
GET /api/vision/modelsGET /api/config/models?input=image&output=textVision understanding models (read images, produce text)

★ The vision list must also constrain output=text: filtering on input=image alone would pull in image-to-image / editing models (input contains image, output is image), which is not what “vision understanding” means.

★ The composite value (provider/modelId) that the old endpoint returned is no longer produced server-side; consumers compose ${provider}/${id} themselves. Existing visionModel values stored in aigc.json therefore keep their format.

Success response 200:

{
  "providers": ["anthropic", "cloudflare", "blksails-ai"],
  "models": [
    {
      "provider": "anthropic",
      "id": "claude-opus-4-5",
      "name": "Claude Opus 4.5",
      "input": ["text", "image"],
      "output": ["text"],
      "source": "self"
    }
  ]
}

Degradation: if the lookup throws (e.g. corrupt models.json) the endpoint returns 200 + an empty list rather than surfacing a 500 to the frontend.

curl 'http://localhost:3000/api/config/models?input=image&output=text'

Implementation reference: packages/core/src/http/routes/config-routes.ts, packages/core/src/model-catalog/service.ts


Attachments API — /api/attachments/**

POST /api/sessions/:id/attachments — Upload an Attachment

This endpoint is registered in the sessions namespace (POST /sessions/:id/attachments, not the attachments route), reusing the Router’s :id session gating (session not found → 404, unauthorized → 401/403).

Request: multipart/form-data, file field name file

Size limit: 25 MiB by default (DEFAULT_MAX_UPLOAD_BYTES). When exceeded, the request is pre-rejected (413) via the Content-Length header before the body is read.

Success response 200:

{
  "attachment": {
    "id": "att_xyz789",
    "name": "screenshot.png",
    "mimeType": "image/png",
    "size": 102400,
    "origin": "upload",
    "sessionId": "550e8400-..."
  },
  "displayUrl": "/api/attachments/att_xyz789/raw?exp=1750000000000&sig=abc...",
  "protocolVersion": "0.1.0"
}

attachment is in the Attachment shape (id/name/mimeType/size/origin/sessionId, see packages/protocol/src/attachment/attachment-dto.ts). displayUrl is an instantly signed delivery URL (presignUrl) with a limited validity period. The attachment id has the form att_<base64url>.

Errors: 400 NO_FILE (no file part or empty file), 413 PAYLOAD_TOO_LARGE (exceeds size limit), 404 (session not found), 401/403 (auth seam rejected)

curl -X POST http://localhost:3000/api/sessions/sess_abc/attachments \
  -F "file=@/path/to/image.png"

GET /api/attachments/:id/raw?exp=&sig= — Download an Attachment

Self-contained signature authentication, not bound to a session; can be accessed directly in the browser (<img src="...">, etc.).

Query parameters:

ParameterDescription
expexpiry time (epoch ms)
sigHMAC-SHA256 signature (hex), generated via PI_WEB_ATTACHMENT_SECRET

Security policy (anti-enumeration): verify the signature first; a missing/invalid/expired signature always yields 401 (existence is not checked, so an attacker cannot tell from the response whether the id exists). Only when the signature is valid are the bytes read and streamed back.

Success response 200: byte stream Response headers: Content-Type: <attachment mime>, Cache-Control: private, max-age=300

Errors: 401 INVALID_SIGNATURE (missing/invalid/expired signature), 404 ATTACHMENT_NOT_FOUND (attachment not found; this code can only be returned when the signature is valid)

Implementation reference: packages/server/src/http/routes/attachment-routes.ts


Session Source Mapping (sessionId → source)

The app-level sessionId → agent source mapping is used on a cold load (directly accessing /session/:id) to restore the .pi/web UI extension configuration; the read happens in GET /api/bootstrap?sessionId= (see resolveResumeSource).

Note: the standalone POST /api/session-source endpoint of the Next.js era was removed along with app/no such route is registered on main (recordSessionSource exists only in tests). The current recovery path reads that mapping first and falls back to the persisted session metadata (resume-meta), so a new session can be recovered by id even without an explicit POST. Cleanup is done incidentally by DELETE /api/sessions/:id (forgetSessionSource, see above).

Implementation reference: lib/app/session-source-map.ts, server/bootstrap.ts:46


createPiWebHandler — Framework-Agnostic Integration

A framework-agnostic factory that returns a standard Web Fetch handler (Request) => Promise<Response>, mountable on any framework compatible with Web Fetch. The host on main mounts it with Hono (see server/index.ts):

import { Hono } from "hono";
import { serve } from "@hono/node-server";
import {
  createPiWebHandler,
  createConfigRoutes,
  createAttachmentRoutes,
} from "@blksails/pi-web-server";
 
const handler = createPiWebHandler({
  manager,          // SessionManager (from session-engine)
  store,            // SessionStore
  authResolver,     // optional, allows through by default
  authorizeSession, // optional, allows through by default
  routes: [         // optional, inject external routes (config / attachment / vision-models …)
    ...createConfigRoutes({ listModelOptions }),
    ...createAttachmentRoutes(attachmentStore),
  ],
  sse: {
    heartbeatMs: 15_000,  // heartbeat interval (milliseconds)
    basePath: "/api",     // route prefix (aligned with the browser side's /api/**)
  },
});
 
const app = new Hono();
// c.req.raw is a standard Request; the Response returned by the handler (including an SSE ReadableStream body) is passed through verbatim.
app.all("/api/*", (c) => handler(c.req.raw));
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });

The production host also registers the webext resources and the /api/bootstrap endpoint before app.all('/api/*') (see Architecture Overview), and cleans up the source mapping after a successful DELETE. The above is the minimal runnable skeleton.

Notes on the injection seams:

  • External routes in opts.routes are merged with the built-in routes, with the built-in routes taking priority (external routes cannot override/shadow a built-in endpoint that has an exact method+path conflict)
  • authResolver(req) rejects → 401; authorizeSession(ctx) returns false → 403
  • For graceful shutdown on SIGTERM, use createPiWebHandlerBundle(opts) instead; it additionally returns shutdown: () => Promise<void> (passing through to manager.shutdown()), and the handler behaves identically to createPiWebHandler

Implementation reference: packages/server/src/http/create-handler.ts


Complete SSE Frame Reference

The SSE stream contains two top-level frame kinds, defined by @blksails/pi-web-protocol’s SseFrameSchema:

kind: uiMessageChunk

Incremental content frame; the payload lives in the chunk field, and chunk.type is an AI SDK v5 standard chunk subtype (see packages/protocol/src/transport/ui-message-chunk.ts), primarily including:

chunk.typeDescription
text-start / text-delta / text-endtext stream (text-delta carries the delta in the delta field, paired with id)
reasoning-start / reasoning-delta / reasoning-endreasoning-process stream
tool-input-start / tool-input-delta / tool-input-availabletool-call input
tool-output-available / tool-output-errortool-call output
start / finish / start-step / finish-step / error / abortmessage lifecycle markers
data-${string} (e.g. data-pi-queue)custom structured data-part (see data-part.ts)

Note: finish is a chunk.type of uiMessageChunk (a message-stream end marker), not a control-frame type.

kind: control

Control frame; the payload lives in the payload field and is discriminated by payload.control. ControlPayloadSchema is a nine-way discriminated union (packages/protocol/src/transport/sse-frame.ts:21-54):

payload.controlDescriptionActually sent?
errorerror / session end (message + optional code)Yes
ui-rpcTier3 UI↔agent RPC downstream response (paired by correlationId)Yes
session-statussession-readiness handshake state (SessionLifecycleState: initializing/ready/error/ended), sticky, replayed on subscribeYes
session-stateauthoritative session snapshot (six fields: lifecycle/busy/turn/stats/model/title), stickyYes (when snapshot authority is enabled)
statestate-injection bridge: authoritative KV change agent→UI downstream mirror (key/value/rev/deleted), sticky per keyYes
logsstructured logs batch-pushed to the frontend panel (entries)Yes
queuequeue status (steering / followUp arrays), stickySent in message-queue scenarios
extension-uiextension UI request (needs POST /ui-response to reply)Sent in extension scenarios
statsusage statisticsnever sent (usage goes through REST or session-state.snapshot.stats)

When writing an SSE control parser (frontend/integrator), you must cover the discriminants above (an unknown control can fall through to a default branch and be safely ignored, staying backward compatible). For session-status (readiness handshake) see packages/protocol/src/transport/session-status.ts; for session-state (authoritative snapshot) see session-state.ts; for state (state bridge) see packages/protocol/src/web-ext/state.ts:15.

JSON structure of each frame:

{
  "kind": "uiMessageChunk",
  "protocolVersion": "0.1.0",
  "chunk": { "type": "text-delta", "id": "t1", "delta": "Hello" }
}

Complete Main-Path Example

The following steps demonstrate the complete flow from creating a session to receiving a response:

  1. Create a session:

    SESSION=$(curl -s -X POST http://localhost:3000/api/sessions \
      -H "Content-Type: application/json" \
      -d '{"source": "/path/to/.pi"}' | jq -r .sessionId)
    echo "Session: $SESSION"
  2. Subscribe to the SSE stream (run in the background; must precede the next step’s POST /messages, or the turn’s reply frames are lost because there is no buffering — see the “Race-condition note” under /stream):

    curl -N "http://localhost:3000/api/sessions/$SESSION/stream" &
    STREAM_PID=$!
  3. Send a message:

    curl -X POST "http://localhost:3000/api/sessions/$SESSION/messages" \
      -H "Content-Type: application/json" \
      -d '{"message": "Hello, agent! What can you do?"}'
  4. Query usage (after inference finishes):

    curl "http://localhost:3000/api/sessions/$SESSION/stats"
  5. Delete the session (incidentally cleaning up the sessionId → source mapping):

    kill $STREAM_PID
    curl -X DELETE "http://localhost:3000/api/sessions/$SESSION"

Common remedies when it doesn’t work: the connection is closed immediately and you receive one payload.control = "error" frame → usually the source path does not exist or the agent failed to start; /messages returns 409 → the session has stopped and must be recreated; the SSE stream emits no frames at all → usually the stream connected later than POST /messages (see the “Race-condition note”), or the host was deployed to Edge/stateless Serverless (which does not support resident subprocesses + long-lived SSE connections). See more in 23 · Troubleshooting FAQ.