openapi.json and the file-based TanStack Start routes under apps/web/src/routes/api/. Regenerate the OpenAPI document with pnpm generate:docs.
Base URL: http://localhost:3000
All request bodies are validated with zod schemas from @workspace/pi-protocol/chat-protocol.zod (packages/pi-protocol). Responses are JSON unless otherwise noted; /api/chat returns an NDJSON event stream.
Hosted and local sessions
On the Cloudflare deployment (FLEET_PI_DEPLOYMENT=cloudflare), new Agent chat sessions are hosted. Each signed-in user has one Pi Durable Object (UserPiDurable, bound as USER_PI_DURABLE), and its SQLite storage is the transcript. Hosted sessions carry a sessionId that starts with durable: and no sessionFile. Neon keeps auth, settings, and an optional session projection.
Requests that send a sessionFile, or a sessionId without the durable: prefix, use the legacy JSONL session path. Local development always uses JSONL session files.
Chat
POST /api/chat
Send a chat message and receive a streaming response. Hosted turns stream from the caller’s Durable Object. Each turn applies the conversation’s selected model, so you can switch models on an existing hosted session. A changed API key, base URL, or API reopens the harness. Request body- 200 — NDJSON stream of
ChatStreamEventlines. See stream events. - 400 — Validation error.
- 401 — Hosted turn without an authenticated user.
- 409 —
{ message: "An agent turn is already in progress." }when a hosted turn is running and the new message cannot join or queue behind it.
POST /api/chat/abort
Abort the active chat session. Request body- 200 —
{ "aborted": true | false } - 500 —
{ "message": "<error>" }
GET /api/chat/models
List available chat models. Responses- 200 —
{ models, selectedModelKey, defaultProvider, defaultModel, defaultThinkingLevel, diagnostics } - 500 —
{ message }
POST /api/chat/models/discover
Discover remote models for a configured provider, for example a custom provider saved without model ids. Request body- 200 —
{ providerId, models }, where each model has the same shape as the/api/chat/modelscatalog entries (key,provider,id,name,reasoning,input,contextWindow,maxTokens,available, …). - 401 — Unauthorized.
GET /api/chat/providers · POST /api/chat/providers · DELETE /api/chat/providers
Read, save, or remove provider credentials.GET reports which providers have credentials configured. POST saves encrypted BYOK credentials for a provider. DELETE removes them.
GET response
authType (apiKey or oauth), providerFamily, displayName, baseUrl, api, and modelIds. Saved openai-chat-completions instances return the baseUrl you saved for them.
Catalog providers include amazon-bedrock, openai, openai-chat-completions, anthropic, google, google-vertex, mistral, groq, openrouter, vercel-ai-gateway, github-copilot, ollama, and daytona for sandbox credentials.
POST request body
providerId and apiKey are required. models is optional and accepts up to 64 ids.
DELETE request body
- 200 —
{ success, providers } - 401 — Unauthorized.
GET /api/chat/commands
List slash commands available in the chat input. Populates the/-triggered command menu with built-ins plus any active skills and prompts. See slash commands for the UX and keyboard shortcuts.
Response
source is one of builtin, skill, prompt, or extension. Skill and prompt entries are omitted when enableSkillCommands is disabled in Pi settings, and diagnostics will explain why.
Responses
- 200 —
{ commands, diagnostics } - 500 —
{ message }
POST /api/chat/new
Create a new chat session. Responses- 200 —
{ sessionFile, sessionId } - 500 —
{ message }
POST /api/chat/fork
Fork a hosted conversation from its latest entry. The fork keeps the parent conversation’s model. Only hosted (durable:) sessions on the Cloudflare deployment can be forked.
Request body
- 200 —
{ sessionId, conversationId }for the new conversation. - 400 —
{ message: "Only a hosted conversation can be forked." } - 401 — Unauthorized.
- 404 —
{ message: "Session not found" }, including deleted sessions. - 409 —
{ message: "An agent turn is already in progress." } - 503 —
{ message: "Hosted agent runtime is unavailable." }
GET /api/chat/provenance
Fetch per-run provenance records. Responses- 200 — provenance payload
- 500 —
{ message }
POST /api/chat/question
Answer aquestionnaire follow-up question raised by the assistant.
Request body
- 200 —
{ ok, message, mode, planAction } - 400 — Bad request.
- 404 —
{ ok: false, message }
GET /api/chat/resources
List available chat resources (skills, prompts, extensions, themes, packages, AGENTS.md files). Responses- 200 —
{ packages, skills, prompts, extensions, themes, agentsFiles, diagnostics } - 500 —
{ message }
POST /api/chat/resume
Resume an existing chat session. Request body- 200 —
{ session, messages, sessionReset } - 500 —
{ message }
GET /api/chat/run
Fetch a single recorded run by itsid query parameter.
Responses
- 200 — Run detail.
- 401 — Unauthorized.
- 404 — Not found.
GET /api/chat/runs
List recorded runs for a session, selected by thesessionId or sessionFile query parameter.
Responses
- 200 — Run list.
- 401 — Unauthorized.
GET /api/chat/session · DELETE /api/chat/session
Hydrate or delete a chat session by query parameters. For a JSONL session,DELETE removes the caller’s owned mirror row in Neon (cascading run, tool, and file mutation rows) and best-effort cleans up the ephemeral JSONL file. For a hosted session (durable: sessionId, no sessionFile), DELETE only hides the conversation in the caller’s Durable Object, because Pi Durable has no per-conversation delete. To erase hosted transcripts, use DELETE /api/chat/account. DELETE requires an authenticated session and passes ownership verification.
GET responses
- 200 —
{ session, messages, sessionReset } - 500 —
{ message }
- 200 —
{ ok: true, sessionId, sessionFile } - 401 — Unauthorized.
- 403 — Session is not owned by the caller.
- 404 — Session not owned or missing (
{ ok: false, reason: "session-not-owned-or-missing" }). - 501 — Mirror disabled (
FLEET_PI_CHAT_DATABASE_URLnot set). - 503 — Mirror unavailable, or the hosted delete failed (
{ ok: false, reason: "delete-failed" }).
DELETE /api/chat/account
Erase all mirrored Pi data for the signed-in user: every row inpi_sessions (cascading transcripts, runs, tool output, and file mutations) and every BYOK provider credential in pi_user_providers. Ownership is enforced under Neon RLS and only the caller’s data is touched. On the Cloudflare deployment, the route also clears the caller’s Durable Object storage, which removes every hosted transcript.
Better Auth identity rows are not deleted — coordinate account deletion in Better Auth separately if required. Workspace files on disk are not reverted.
Responses
- 200 —
{ ok: true, scope: "pi-mirror" | "pi-mirror+hosted", erasedSessions, erasedProviders, erasedHostedTranscripts, message } - 401 — Unauthorized.
- 500 —
{ ok: false, reason, message }
GET /api/chat/sessions
List all chat sessions. Responses- 200 —
{ sessions } - 500 —
{ message }
GET /api/chat/settings · PATCH /api/chat/settings
Read or patch effectiveChatPiSettings: compaction, retry, default provider, model and thinking level, enabled models, skill commands, extensions, packages, prompts, skills, themes, transport, code mode (codemode), MCP servers (mcpServers), and steering / follow-up delivery modes. PATCH persists project overrides and hot-reloads active runtimes. Hosted conversations apply saved compaction, retry, thinking, and steering settings on their next turn.
PATCH request body
Workspace
Workspace endpoints read and write the durable layer described by the adaptive workspace contract.Every
/api/workspace/* route resolves the caller’s user-scoped workspace and rejects unauthenticated callers with 401 Unauthorized on protected deployments (Cloudflare, Neon Managed Auth, or FLEET_PI_CHAT_RUNTIME_REQUIRE_AUTH=1). Send either a Neon Auth Authorization: Bearer <token> header or an authenticated session cookie. Local development without auth configured continues to serve the repo-scoped workspace.GET /api/chat/commands serve the built-in workspace stored in the user’s Durable Object. They return 403 with { "message": "daytona_credential_required" } only when the deployment has no Durable Object binding. In that case GET /api/workspace/health reports a degraded 503 instead.
GET /api/workspace/health
Workspace contract health (manifest version, missing canonical paths, projection state) and seed template state. A200 response returns status, workspaceAvailable, bootstrapComplete, projectionStatus and template. template is { version, pendingUpdateVersions }:
versionis the template version last applied to the workspace, ornullwhen none was recorded (for example, local workspaces).pendingUpdateVersionslists, in ascending order, the template versions with updated copies waiting for review under.fleet/template-updates/.
template is null when the workspace is unavailable. The degraded 503 body has no template field.
GET /api/workspace/file
Read a canonical workspace file.GET /api/workspace/item · GET /api/workspace/items
Fetch a single workspace item or list items.GET /api/workspace/tree
Browse the workspace tree.GET /api/workspace/search
Search workspace items via the projection index inagent-workspace/indexes/.
GET /api/workspace/reindex · POST /api/workspace/reindex
Rebuild the projection index. The canonical files are not modified — the index is recomputed. On protected deployments,POST /api/workspace/reindex is protected by a double-submit CSRF token and a per-user rate limit:
- Rate limit — up to 5 requests per minute per user. Excess calls return
429 Too Many RequestswithRetry-After: 60. - CSRF token — cookie-authenticated callers must first
GET /api/workspace/reindexto receive afleet_pi_csrfcookie (HttpOnly,SameSite=Strict) plus acsrfTokenin the response body, then echo the token back on thePOSTas thex-fleet-csrf-tokenheader. Same-originOrigin(orReferer) is also required. Bearer-token callers skip CSRF because they are not vulnerable to cross-site cookie replay.
- 200 — reindex completed.
- 403 —
{ "message": "Invalid CSRF protection" }when the CSRF token, cookie, or same-origin check fails. - 429 —
{ "message": "Workspace reindex rate limit exceeded" }withRetry-After: 60.
Outbound network safety
The chat runtime’sweb_fetch tool only accepts public HTTPS URLs. Requests to localhost, loopback, RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (169.254.0.0/16), or IPv6 unique-local (fc00::/7) addresses are rejected before any connection is opened, and DNS lookups are pinned so a public hostname cannot rebind to a private address between resolution and connect. GitHub blob/ URLs are automatically rewritten to raw content URLs. Only text/* and application/json responses are returned; binary payloads are refused.
Sandbox
Daytona-backed user sandboxes. Each authenticated user gets one isolated sandbox in their Daytona account (BYOK). Tool calls run inside the container, and a persistent volume mounted at/home/daytona/agent-workspace keeps the user’s workspace across restarts. All routes require Better Auth.
GET /api/sandbox/preview
Return a signed preview URL into the calling user’s active Daytona sandbox.
Responses
- 200 —
{ url }— short-lived preview URL into the sandbox. - 400 —
{ error: "Invalid port" } - 401 —
{ error: "Authentication required" } - 404 —
{ error: "No active sandbox for user" } - 503 —
{ error: "Sandbox not available" }— Daytona is not enabled for the calling user. On the Cloudflare deployment this typically means the user has not saved adaytonaprovider secret; locally it usually meansDAYTONA_API_KEYis unset.
GET /api/sandbox/settings · POST /api/sandbox/settings
Read Daytona sandbox configuration and readiness, or update the local Daytona environment configuration. GET responsedaytonaApiKeyConfigured is true when DAYTONA_API_KEY is set on the server or you saved a daytona provider secret. sandboxReady is true once Fleet Pi has a cached Daytona sandbox for your user.
POST request body
- 200 — Sandbox status (
GET) or configuration saved (POST). - 401 — Unauthorized.
- 403 —
POSTonly: invalid CSRF protection.
POST /api/webhooks/daytona
Receive lifecycle events from Daytona (sandbox start, stop, archive, delete). Fleet Pi verifies thex-daytona-signature header as an HMAC-SHA256 of the raw request body keyed by DAYTONA_WEBHOOK_SECRET, compared in constant time. When the secret is unset, local deployments accept and log the call but ignore it, and hosted deployments return 503.
Headers
x-daytona-signature— Hex-encoded HMAC-SHA256 of the raw request body, optionally prefixed withsha256=.
- 200 —
{ received: true } - 401 —
{ error: "invalid signature" } - 503 —
{ error: "webhook secret is not configured" }on a hosted deployment. - 500 —
{ error: "Webhook processing failed" }
Authentication
Fleet Pi routes follow a deployment-based auth policy:- Local development (no
NEON_AUTH_BASE_URL/NEON_AUTH_URLand not running on Cloudflare): chat and workspace endpoints allow anonymous access so the app works without login. - Cloudflare (
FLEET_PI_DEPLOYMENT=cloudflare), Neon Managed Auth, orFLEET_PI_CHAT_RUNTIME_REQUIRE_AUTH=1: session, catalog, and workspace routes require authentication. This covers/api/chat/*streaming and session routes, the catalog endpoints (/api/chat/models,/api/chat/resources,/api/chat/commands,/api/chat/models/discover), and every route under/api/workspace/*. Unauthenticated requests receive401. /api/sandbox/previewalways requires authentication.
authClient.token() and send Authorization: Bearer <jwt> on every request. Better Auth clients use signed session cookies.
/api/auth/*
Proxies Neon Managed Auth when NEON_AUTH_BASE_URL (or NEON_AUTH_URL) is set. Otherwise it serves Better Auth when BETTER_AUTH_SECRET is set. See configuration and the Neon Managed Auth reference for required environment variables.
GET /api/auth/session
Return the active authentication session for the caller, ornull when nobody is signed in.
Health
GET /api/health
Smoke endpoint used by the quickstart. Responses- 200 —
{ "status": "ok" }
Stream events
/api/chat emits one JSON object per newline. Every event has a type discriminator. The full union is ChatStreamEvent in chat-protocol.ts.
Every turn ends with a terminal done frame. When the user stops a turn, done carries stopReason: "aborted" and message holds whatever streamed before the abort. When a turn fails, an error frame comes first, followed by done with stopReason: "error". A done frame without stopReason means the turn completed normally.
Workspace status
The firststart frame of a request can carry a workspace field that tells the client whether the model has workspace tools:
reason is daytona_credential_required. The model then works on the built-in workspace with only the read, search, write, and edit tools. Add a Daytona key under Settings → Providers to enable shell and sandbox tools. daytona_sandbox_not_ready means a key is configured but the sandbox is still warming up. Later start frames in the same request omit the field.
Plan state
Plan events carry aChatPlanState shape:
Related
Chat modes
Which tools each mode unlocks.
Architecture
How requests flow from the browser through the configured model provider and back.