Server API Reference
The relay server exposes a REST-like HTTP API used by both the web UI and the CLI. Most endpoints require authentication via an API key (CLI) or session cookie (browser). The real-time event channel is Socket.IO; this page covers the HTTP surface only.
Authentication
Section titled “Authentication”API Key (CLI)
Section titled “API Key (CLI)”Pass your API key in the x-api-key header:
x-api-key: <64-character hex key>Session Cookie (Browser)
Section titled “Session Cookie (Browser)”The web UI uses cookie-based sessions managed by better-auth. Log in via POST /api/auth/sign-in/email.
Health
Section titled “Health”GET /health
Section titled “GET /health”Returns server status. No authentication required.
GET /healthResponse:
{ "status": "ok", "redis": true, "socketio": true, "uptime": 1234, "version": { "server": "0.80.3", "socketProtocol": 1, "buildTimestamp": "2026-07-09T00:00:00.000Z" }}Authentication Endpoints
Section titled “Authentication Endpoints”Authentication is handled by better-auth at the /api/auth/* prefix. See the better-auth documentation for the full list of endpoints.
POST /api/register
Section titled “POST /api/register”Register a new account or log in to an existing one and issue a CLI API key. Used by pizzapi setup.
Rate-limited: 5 requests per 15 minutes per IP.
Request body:
{ "name": "Jordan", // Required for new accounts; omit for existing "email": "you@example.com", "password": "••••••••"}Response:
{ "ok": true, "key": "a1b2c3d4e5f6..." }Keys are 64-character hex strings generated with crypto.randomBytes(32).
Runners
Section titled “Runners”GET /api/runners
Section titled “GET /api/runners”List all registered runner daemons.
Auth: Session cookie.
Response:
{ "runners": [ { "runnerId": "runner_a1b2c3", "name": "my-macbook", "roots": ["/Users/jordan/Projects"], "sessionCount": 1, "skills": [], "agents": [], "version": "0.80.3", "platform": "darwin", "serviceIds": ["git", "godmother"], "panels": [], "triggerDefs": [], "sigilDefs": [], "warnings": [] } ]}POST /api/runners/spawn
Section titled “POST /api/runners/spawn”Spawn a new agent session on a runner.
Auth: API key or session cookie.
Request body:
{ "runnerId": "runner_a1b2c3", "prompt": "Fix all TypeScript errors", "cwd": "/path/to/project", "model": { "provider": "anthropic", "id": "claude-opus-4-5" }, "agent": { "name": "reviewer", "systemPrompt": "Optional override", "tools": "read,bash", "disallowedTools": "write-file" }, "parentSessionId": "session_parent", "resumePath": "/path/to/state.jsonl", "resumeId": "session_to_resume"}Response:
{ "ok": true, "runnerId": "runner_a1b2c3", "sessionId": "session_x1y2z3", "pending": false}If the runner’s spawn acknowledgement times out, pending is true.
An optional idempotencyKey (string, up to 200 characters) makes retries safe:
a repeat request from the same user for the same runner and key within ten
minutes (per relay process) returns the original sessionId with "deduplicated": true
instead of spawning again. Replay re-checks that the caller owns the runner,
using the durable owner record when the runner is offline, so a retry after a
lost response still succeeds even if the runner has since disconnected. If
neither live state nor the durable record names the caller as the owner, the
cache is not consulted.
POST /api/runners/restart
Section titled “POST /api/runners/restart”Restart the runner daemon (and all sessions running on it).
Auth: Session cookie.
Request body:
{ "runnerId": "runner_a1b2c3"}Response:
{ "ok": true }POST /api/runners/stop
Section titled “POST /api/runners/stop”Stop (shut down) a runner daemon.
Auth: Session cookie.
Request body:
{ "runnerId": "runner_a1b2c3"}Response:
{ "ok": true }POST /api/runners/terminal
Section titled “POST /api/runners/terminal”Register a new terminal (xterm) session on a runner.
Auth: Session cookie.
Request body:
{ "runnerId": "runner_a1b2c3", "cwd": "/path/to/project", // optional "cols": 80, // optional, default 80 "rows": 24 // optional, default 24}Response:
{ "ok": true, "terminalId": "uuid", "runnerId": "runner_a1b2c3" }GET /api/runners/{runnerId}/recent-folders
Section titled “GET /api/runners/{runnerId}/recent-folders”Get recently used working directories for a runner.
Auth: Session cookie.
Response:
{ "folders": ["/Users/jordan/Projects/app", "/Users/jordan/Projects/lib"]}DELETE /api/runners/{runnerId}/recent-folders
Section titled “DELETE /api/runners/{runnerId}/recent-folders”Remove a folder from the recent-folders list.
Auth: Session cookie.
Request body:
{ "path": "/Users/jordan/Projects/old-project" }Response:
{ "ok": true, "deleted": true }GET /api/runners/{runnerId}/models
Section titled “GET /api/runners/{runnerId}/models”List available AI models on a runner, excluding user-hidden models.
Auth: Session cookie.
Response:
{ "models": [ { "provider": "anthropic", "id": "claude-sonnet-4-20250514" }, { "provider": "openai", "id": "gpt-4o" } ]}GET /api/runners/{runnerId}/services
Section titled “GET /api/runners/{runnerId}/services”List registered services and UI panels for a runner.
Auth: Session cookie.
Response:
{ "serviceIds": ["git", "godmother"], "disabledServiceIds": [], "panels": [{ "id": "panel-1", "title": "Service Panel" }], "triggerDefs": [], "sigilDefs": []}GET /api/runners/{runnerId}/skills
Section titled “GET /api/runners/{runnerId}/skills”List available skills on a runner (from Redis cache).
Auth: Session cookie.
Response:
{ "skills": ["deploy", "review-pr", "run-tests"]}GET /api/runners/{runnerId}/skills/{name}
Section titled “GET /api/runners/{runnerId}/skills/{name}”Get the full content of a specific skill.
Auth: Session cookie.
Response:
{ "name": "deploy", "content": "# Deploy Skill\n..." }POST /api/runners/{runnerId}/skills
Section titled “POST /api/runners/{runnerId}/skills”Create a new skill on the runner.
Auth: Session cookie.
Request body:
{ "name": "my-skill", "content": "# Skill instructions..." }Response:
{ "ok": true, "skills": ["my-skill", "deploy", "review-pr"] }POST /api/runners/{runnerId}/skills/refresh
Section titled “POST /api/runners/{runnerId}/skills/refresh”Ask the runner to re-scan skills from disk.
Auth: Session cookie.
Response:
{ "ok": true, "skills": ["deploy", "review-pr"] }PUT /api/runners/{runnerId}/skills/{name}
Section titled “PUT /api/runners/{runnerId}/skills/{name}”Update an existing skill.
Auth: Session cookie.
Request body:
{ "content": "# Updated skill content..." }Response:
{ "ok": true, "skills": ["deploy", "review-pr"] }DELETE /api/runners/{runnerId}/skills/{name}
Section titled “DELETE /api/runners/{runnerId}/skills/{name}”Delete a skill from the runner.
Auth: Session cookie.
Response:
{ "ok": true, "skills": ["deploy"] }POST /api/runners/{runnerId}/files
Section titled “POST /api/runners/{runnerId}/files”List files in a directory on the runner. Path must be within the runner’s allowed workspace roots.
Auth: Session cookie.
Request body:
{ "path": "/Users/jordan/Projects/app" }Response:
{ "ok": true, "files": [ { "name": "src", "type": "directory" }, { "name": "package.json", "type": "file" } ]}POST /api/runners/{runnerId}/search-files
Section titled “POST /api/runners/{runnerId}/search-files”Search for files by name within a directory on the runner.
Auth: Session cookie.
Request body:
{ "cwd": "/Users/jordan/Projects/app", "query": "component", "limit": 100 // optional, default 100}Response:
{ "ok": true, "files": ["src/components/Button.tsx", "src/components/Modal.tsx"]}POST /api/runners/{runnerId}/read-file
Section titled “POST /api/runners/{runnerId}/read-file”Read the contents of a file on the runner. Supports UTF-8 text and base64-encoded binary.
Auth: Session cookie.
Request body:
{ "path": "/Users/jordan/Projects/app/src/index.ts", "encoding": "utf8" // "utf8" (default, 512KB max) or "base64" (10MB max)}Response:
{ "ok": true, "content": "import express from 'express';\n..." }Git Operations
Section titled “Git Operations”Git operations (status, diff, branches, checkout, stage, unstage, commit, push) are handled entirely through the Socket.IO service_message channel with serviceId="git", not via REST endpoints. See the Architecture and protocol overview for the namespace layout.
GET /api/runners/{runnerId}/sandbox-status
Section titled “GET /api/runners/{runnerId}/sandbox-status”Get the current sandbox configuration and status for a runner.
Auth: Session cookie.
Response:
{ "ok": true, "mode": "basic", "filesystem": { "denyRead": [], "allowWrite": ["/tmp"], "denyWrite": [] }, "network": { "allowedDomains": [], "deniedDomains": [] }}PUT /api/runners/{runnerId}/sandbox-config
Section titled “PUT /api/runners/{runnerId}/sandbox-config”Update the sandbox configuration for a runner.
Auth: Session cookie.
Request body:
{ "mode": "basic", // "none" | "basic" | "full" "filesystem": { "denyRead": [], "allowWrite": ["/tmp", "/Users/jordan/Projects"], "denyWrite": ["/etc"] }, "network": { "allowedDomains": ["api.github.com"], "deniedDomains": [] }}Response: The runner’s acknowledgement object. The server forwards the runner command result verbatim, so the body may include additional fields beyond ok.
GET /api/runners/{runnerId}/usage
Section titled “GET /api/runners/{runnerId}/usage”Get token/cost usage statistics from the runner.
Auth: Session cookie.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
range |
string | 90d |
Time range: 7d, 30d, 90d, or all |
Response:
{ "ok": true, "totalInputTokens": 1234567, "totalOutputTokens": 456789, "totalCostUsd": 12.34, "sessions": []}GET /api/runners/{runnerId}/triggers
Section titled “GET /api/runners/{runnerId}/triggers”List trigger definitions and a legacy listener projection. Use /api/routes to query and manage active routes.
Auth: Session cookie.
Response:
{ "triggerDefs": [ { "type": "github:push", "description": "Fires on git push" } ], "listeners": [ { "triggerType": "github:push", "prompt": "Review the push", "cwd": "/project" } ]}Legacy trigger-listener compatibility endpoints
Section titled “Legacy trigger-listener compatibility endpoints”The GET/POST /api/runners/{runnerId}/trigger-listeners and
PUT/DELETE /api/runners/{runnerId}/trigger-listeners/{target} endpoints are
compatibility wrappers over unified Routes. Use /api/routes
for route management; route IDs are the stable subscription identifiers.
GET /api/runners/{runnerId}/trigger-listeners
List the runner’s session and spawn routes in the legacy listener shape.
Auth: Session cookie.
Response:
{ "listeners": [{ "listenerId": "rt_abc123", "triggerType": "github:push", "prompt": "...", "cwd": "..." }] }POST /api/runners/{runnerId}/trigger-listeners
Create a spawn route through the compatibility interface. New clients should use POST /api/routes.
Auth: Session cookie.
Request body:
{ "triggerType": "github:push", "prompt": "Review the changes", // optional "cwd": "/path/to/project", // optional "model": { "provider": "anthropic", "id": "claude-sonnet-4-20250514" }, // optional "params": { "branch": "main" }, // optional params forwarded to the source service "autoClose": true // optional}Response:
{ "ok": true, "listenerId": "rt_abc123", "triggerType": "github:push" }PUT /api/runners/{runnerId}/trigger-listeners/{target}
Update a route through the compatibility interface. It supports pause/resume for session routes and legacy listener edits for spawn routes. {target} may be a route/listener ID; trigger-type targets retain legacy behavior. New clients should use PUT /api/routes/{routeId}.
Auth: Session cookie.
Request body: Same fields as POST (all optional).
Response:
{ "ok": true, "listenerId": "rt_abc123", "triggerType": "github:push" }DELETE /api/runners/{runnerId}/trigger-listeners/{target}
Delete a route through the compatibility interface. {target} may be a route/listener ID; trigger-type targets retain legacy bulk-delete behavior for spawn routes. New clients should use DELETE /api/routes/{routeId}.
Auth: Session cookie.
Response:
{ "ok": true, "listenerId": "rt_abc123", "triggerType": "github:push", "removed": 1 }GET /api/runners/{runnerId}/agents
Section titled “GET /api/runners/{runnerId}/agents”List available agent definitions on a runner (from Redis cache).
Auth: Session cookie.
Response:
{ "agents": ["reviewer", "deployer"] }GET /api/runners/{runnerId}/agents/{name}
Section titled “GET /api/runners/{runnerId}/agents/{name}”Get the full content of a specific agent definition.
Auth: Session cookie.
Response:
{ "name": "reviewer", "content": "# Agent definition..." }POST /api/runners/{runnerId}/agents
Section titled “POST /api/runners/{runnerId}/agents”Create a new agent definition.
Auth: Session cookie.
Request body:
{ "name": "my-agent", "content": "# Agent instructions..." }Response:
{ "ok": true, "agents": ["my-agent", "reviewer"] }POST /api/runners/{runnerId}/agents/refresh
Section titled “POST /api/runners/{runnerId}/agents/refresh”Ask the runner to re-scan agent definitions from disk.
Auth: Session cookie.
Response:
{ "ok": true, "agents": ["reviewer", "deployer"] }PUT /api/runners/{runnerId}/agents/{name}
Section titled “PUT /api/runners/{runnerId}/agents/{name}”Update an existing agent definition.
Auth: Session cookie.
Request body:
{ "content": "# Updated agent content..." }Response:
{ "ok": true, "agents": ["reviewer", "deployer"] }DELETE /api/runners/{runnerId}/agents/{name}
Section titled “DELETE /api/runners/{runnerId}/agents/{name}”Delete an agent definition.
Auth: Session cookie.
Response:
{ "ok": true, "agents": ["deployer"] }GET /api/runners/{runnerId}/plugins
Section titled “GET /api/runners/{runnerId}/plugins”List Claude Code plugins available on the runner. Optionally pass ?cwd= to scan from a specific directory.
Auth: Session cookie.
Query params:
| Param | Type | Description |
|---|---|---|
cwd |
string | Optional absolute path to scan for project-local plugins |
Response:
{ "plugins": [{ "name": "my-plugin", "path": "/path/to/plugin" }] }POST /api/runners/{runnerId}/plugins/refresh
Section titled “POST /api/runners/{runnerId}/plugins/refresh”Ask the runner to re-scan plugins from disk.
Auth: Session cookie.
Response:
{ "ok": true, "plugins": [] }Sessions
Section titled “Sessions”GET /api/sessions
Section titled “GET /api/sessions”List all sessions for the authenticated user, including both live (in-memory) and persisted sessions.
Auth: Session cookie.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
includePersisted |
string | true |
Set to false or 0 to exclude persisted (historical) sessions |
Response:
{ "sessions": [ { "sessionId": "session_x1y2z3", "runnerId": "runner_a1b2c3", "status": "running" } ], "persistedSessions": []}POST /api/sessions/{sessionId}/attachments
Section titled “POST /api/sessions/{sessionId}/attachments”Upload file attachments to a session (e.g. for the agent to process). Accepts one or more files.
Auth: Session cookie.
Content-Type: multipart/form-data
Form fields: file or files (one or more File entries).
Max file size: Configured by the server (default: 30 MB, via PIZZAPI_ATTACHMENT_MAX_FILE_SIZE_BYTES). The HTTP body limit for upload paths is 50 MB.
Response:
{ "attachments": [ { "attachmentId": "att_uuid", "filename": "screenshot.png", "mimeType": "image/png", "size": 54321, "expiresAt": "2025-01-15T12:00:00Z" } ]}GET /api/attachments/{attachmentId}
Section titled “GET /api/attachments/{attachmentId}”Download a previously uploaded attachment. Returns the file with appropriate content headers.
Auth: Session cookie, API key in the x-api-key header, or a short-lived attachment-scoped ?token= minted by POST /api/attachments/{attachmentId}/token (used by the mobile app for <img> URLs). A durable API key in the ?apiKey= query parameter is rejected with 401, because URLs end up in proxy logs and history. Relays that must still serve mobile builds predating scoped tokens can set PIZZAPI_ALLOW_ATTACHMENT_QUERY_API_KEY=true temporarily; each such request is logged as deprecated.
Response: Binary file with headers:
Content-Type— the attachment’s MIME typeContent-Disposition—inline; filename="..."X-Attachment-Id— the attachment IDX-Attachment-Filename— percent-encoded original filename
Triggers
Section titled “Triggers”Tenant isolation. Every Event is stamped with the authenticated publisher’s
source.userId, and every Route carries an ownerUserId (stamped server-side
from the creating principal — client-supplied values are ignored). A Route only
matches Events whose source.userId equals its ownerUserId, so one user’s
publish can never fire another user’s session or spawn routes, and webhook
event types can’t cross-fire between users even when their names collide.
fireId idempotency is likewise scoped per owner. The only exception is a
config-file Route (PIZZAPI_ROUTES_FILE) with no ownerUserId: it is
operator-level and matches all users. Legacy rows whose owner could not be
backfilled at startup match nothing (they’re logged and remain deletable).
Config session targets are bound to a principal. A config-file Route with a
session target must name the user who owns that session, either as
target.ownerUserId or (for a tenant-scoped route) through the route’s
ownerUserId, which is the default. The relay refuses to load a routes file
with an unbound session target. Only a session registered by the bound
user receives the route’s deliveries, whether live or drained on
registration. The target session ID stays reserved for that user after the
session’s ownership record is pruned: another user who registers that ID is
given a fresh session ID instead. Rows synced before this requirement that
have no binding deliver to nobody until the file is fixed. target.ownerUserId
is ignored on API-created routes.
A live delivery requires the target session’s current ownership record to name the bound user; a missing record is treated as a mismatch, and the delivery stays queued. The bound user is also recorded on each queued Delivery when it is created, so removing or changing the route (a config reload, session cleanup, or an edit that changes the route’s ID) never releases already-queued deliveries to anyone else. At startup the relay backfills this field on deliveries queued by an older version from their route; a queued config delivery whose route is already gone is delivered to nobody.
{ "routes": [ { "eventType": "github:pr_comment", "target": { "kind": "session", "sessionId": "ops-session", "ownerUserId": "operator-user-id" }, "deliverAs": "followUp" } ]}POST /api/events — publish an Event
Section titled “POST /api/events — publish an Event”The one fire path (ADR-0002): publish an Event; Routes decide which sessions
receive it. A target.sessionId makes an implicit single-session route
(ownership-checked); without one, existing routes for the event type deliver.
Auth: API key (x-api-key header) or session cookie.
Request body:
{ "type": "github:pr_opened", // namespaced event type "payload": { "repo": "my/repo" }, // optional; defaults to {} "summary": "PR #12 opened", // optional human-readable summary "fireId": "unique-if-retried", // optional: idempotency + response correlation "routeIds": ["rt_abc123"], // optional: deliver only through these routes "target": { // optional: direct one-session delivery "sessionId": "sess_abc", "deliverAs": "steer", // "steer" (default, interrupts) or "followUp" (queues) "wake": true // legacy direct-target wake behavior }, "source": { "kind": "service", "id": "github" } // optional; defaults from auth}payload is optional and defaults to {}. routeIds is an explicit delivery allowlist: only those route IDs are considered.
Each selected route still passes the normal enabled-state, owner, event-type,
and filter checks. Omit it to deliver through all matching routes. Direct
target.sessionId delivery is separate from route delivery.
For a direct target.sessionId, target.wake: true asks to resume an offline session.
Response:
{ "ok": true, "eventId": "evt_abc123", "created": true, // false when fireId dedup hit "deliveries": [{ "deliveryId": "dlv_1", "sessionId": "sess_abc", "status": "delivered" }], "spawnedSessions": []}GET /api/events — global feed
Section titled “GET /api/events — global feed”List published events. Query: type, before (ISO timestamp cursor), limit.
Auth: API key or session cookie.
Scoping: only events the caller can see — events they sourced (their own sessions, or their own API identity) and events with a delivery into a session they own. Other users’ payloads never cross the boundary.
GET /api/events/{eventId}/deliveries
Section titled “GET /api/events/{eventId}/deliveries”List deliveries recorded for an event, ordered by update time.
Auth: API key or session cookie.
Scoping: only deliveries into sessions owned by the caller are returned. An
unknown event returns 404; an existing event for which the caller owns no
target sessions returns an empty list.
Response:
{ "deliveries": [ { "deliveryId": "dlv_abc", "eventId": "evt_abc123", "eventType": "github:pr_opened", "sessionId": "sess_abc", "routeId": "rt_abc123", "deliverAs": "steer", "status": "delivered", "createdAt": "2025-01-15T12:00:00.000Z", "deliveredAt": "2025-01-15T12:00:01.000Z" } ]}routeId, deliveredAt, respondedAt, response, and expiresAt are present
only when applicable. status is:
pending— queued, the target session is not available (yet)inflight— emitted to an ack-capable session; waiting for its receipt acknowledgement (transient — settles todeliveredon the ack, or back topendingon ack timeout/disconnect for re-delivery)delivered— handed to the session (ack-confirmed by the recipient, or legacy handoff for older CLIs)responded— the response contract was answeredescalated— unanswered, re-routed up the escalation chainexpired— TTL lapsed with no answer / no recipient
GET /api/sessions/{sessionId}/deliveries
Section titled “GET /api/sessions/{sessionId}/deliveries”Per-session delivery view (feed + respond surface). Ordered by update time.
Auth: API key or session cookie.
POST /api/deliveries/{deliveryId}/response
Section titled “POST /api/deliveries/{deliveryId}/response”Answer a contract-bearing delivery (e.g. a plan review). Records the response against the delivery and relays it back to the publishing session.
Auth: API key or session cookie.
Request body:
{ "response": "Approved — go ahead.", "action": "approve" }The action must be one of the event’s declared contract actions, when any.
Response:
{ "ok": true, "deliveryId": "dlv_abc", "relayed": true }GET /api/routes — list routes
Section titled “GET /api/routes — list routes”Routes are subscriptions (ADR-0002): an event type + a target (a session, or a
spawn spec for auto-spawn listeners). Query: eventType. Only routes the caller
owns (directly, or through the route’s session or runner owner for legacy rows)
are returned. Legacy routes whose owner cannot be resolved from any record are
quarantined: they never match events and are hidden from ordinary users; only
users listed in PIZZAPI_ROUTE_RECOVERY_USER_IDS see them. There is
no GET /api/routes/{routeId} endpoint; list routes and filter client-side when
you need one route by id.
Routes whose runner is dead — no registration for 7+ days and no live socket on
any relay node — carry runnerDead: true and runnerDeadSince (ISO date of
the last registration). Such routes can never fire; delete them individually or
with DELETE /api/runners/{runnerId}/routes.
Auth: API key or session cookie.
DELETE /api/runners/{runnerId}/routes — bulk-delete a runner’s routes
Section titled “DELETE /api/runners/{runnerId}/routes — bulk-delete a runner’s routes”Delete every route stamped with the runner (spawn listeners via
spec.runnerId, session subscriptions/schedules via target.runnerId) and
emit an unsubscribe reconcile delta per session route. Config-origin routes
(read-only) and webhook routes (rt_wh_*, managed by the webhooks surface)
are skipped and counted. Intended for cleaning up after a dead runner.
Auth: API key or session cookie; the caller must own the runner (live state
or the durable owner record). For a runner with no resolvable owner, a caller
removes only routes stamped with their own ownerUserId; unstamped routes on
such a runner can be removed only by a user listed in
PIZZAPI_ROUTE_RECOVERY_USER_IDS, and only when no record resolves an owner
for the route itself. Routes stamped for another user (on the route or in its
spawn spec) are never selected by recovery cleanup.
Response:
{ "ok": true, "removed": 4, "skipped": 1 }POST /api/routes — create a route
Section titled “POST /api/routes — create a route”Subscribe a session to an event type or register an auto-spawn route. Routes are the unified API for both session subscriptions and auto-spawn listeners.
Auth: API key or session cookie.
Request body (session subscription):
{ "eventType": "github:pr_opened", "target": { "kind": "session", "sessionId": "sess_abc", "offlinePolicy": "wait" }, "deliverAs": "followUp", // "steer" or "followUp" "params": { "branch": "main" }, // optional, forwarded to the emitting service "filters": [ // optional payload filters { "field": "repo", "value": "my/repo", "op": "eq" } // "eq" | "contains"; add "caseSensitive": true for exact casing ], "filterMode": "and", // optional: "and" (default) or "or" "origin": "agent" // optional: "agent" or "ui"}For a session route target, offlinePolicy is wait (default; leave delivery
pending), wake (attempt to resume the session), or fail (fail terminally).
The legacy wake: true field remains accepted and means offlinePolicy: "wake".
The legacy POST /api/runners/{runnerId}/trigger-listeners wrapper also
converts params to filters for spawn listeners (except time:* schedules)
and defaults deliverAs to followUp. New clients should specify filters
and deliverAs explicitly.
Request body (auto-spawn listener):
{ "eventType": "github:pr_opened", "target": { "kind": "spawn", "spec": { "runnerId": "runner_a1b2c3", "promptTemplate": "Review this PR", "cwd": "/work/repo", "autoClose": true } }, "deliverAs": "followUp"}Response:
{ "ok": true, "route": { "routeId": "rt_abc123", "eventType": "github:pr_opened", "...": "..." } }The routeId doubles as the subscription id: pass it to
PUT/DELETE /api/routes/{routeId} to update or unsubscribe.
PUT /api/routes/{routeId} — update a route
Section titled “PUT /api/routes/{routeId} — update a route”Update params/filters, or re-own a schedule by changing target.sessionId on
the same runner (the route keeps its id, so that runner’s durable schedule state
survives). Moving a schedule to another runner preserves the route and history,
but runtime progress is local to the scheduler and is not transferred; the new
runner initializes from the route parameters.
Target changes are re-validated like POST: a spawn target must reference a
runner owned by the caller (the fail-closed owner stamp is re-applied), and a
session target must be owned by the caller.
Auth: API key or session cookie.
DELETE /api/routes/{routeId} — delete a route
Section titled “DELETE /api/routes/{routeId} — delete a route”Unsubscribe. Sessions that have ended remain manageable: authorization falls
back through the route’s stamped runner owner, so a standing schedule is
always cancellable by its owner. A legacy route with no resolvable owner
returns 404 to ordinary users; a user listed in
PIZZAPI_ROUTE_RECOVERY_USER_IDS may delete it (but PUT on such a route
returns 403, so recovery never adopts or re-arms it).
Auth: API key or session cookie.
Response:
{ "ok": true }POST /api/runners/{runnerId}/trigger-listeners/{routeId}/fire — test a route
Section titled “POST /api/runners/{runnerId}/trigger-listeners/{routeId}/fire — test a route”Auth: Session cookie.
Publish a test Event through only this route. The body must contain a payload
object; the endpoint checks required fields and top-level property types when a
schema is advertised. It returns { "ok": true, "eventId": "..." }. This
compatibility URL accepts a route ID for route-specific test delivery.
GET /api/runners/{runnerId}/schedules — list schedules
Section titled “GET /api/runners/{runnerId}/schedules — list schedules”Auth: Session cookie.
List the caller-visible time:* routes associated with this runner, including
session-targeted and spawn-target schedules. The response includes schedules
with each route’s subscriptionId, target, sessionId, runnerId,
triggerType, sessionName, cwd, and sessionLive; configured params,
filters, and filterMode are also included. Spawn-target schedules have
sessionId: "", sessionName: null, and sessionLive: false.
GET /api/sessions/{sessionId}/triggers
Section titled “GET /api/sessions/{sessionId}/triggers”List recent trigger history for a session.
Auth: API key or session cookie.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
limit |
number | 50 |
Max triggers to return (capped at 200) |
Response:
{ "triggers": [ { "triggerId": "ext_abc123", "type": "webhook", "source": "external:github", "summary": "New push to main", "payload": {}, "deliverAs": "steer", "ts": "2025-01-15T12:00:00Z", "direction": "inbound" } ], "childSessionIds": []}childSessionIds lists history sources whose live session metadata still links them to this parent. Retained membership alone does not count an ended child. History remains available after a child ends. If child-state lookup fails, the endpoint returns 503 rather than an unfiltered success response.
DELETE /api/sessions/{sessionId}/triggers
Section titled “DELETE /api/sessions/{sessionId}/triggers”Clear trigger history for a session.
Auth: API key or session cookie.
Response:
{ "ok": true }GET /api/sessions/{sessionId}/available-triggers
Section titled “GET /api/sessions/{sessionId}/available-triggers”List trigger types available on the session’s runner (from runner service catalog).
Auth: API key or session cookie.
Response:
{ "triggerDefs": [ { "type": "github:push", "description": "Fires on git push events", "params": [{ "name": "branch", "type": "string" }], "schema": {} } ]}Webhooks
Section titled “Webhooks”Webhooks allow external services to publish Events via HMAC-authenticated HTTP requests. Routes determine whether each Event is delivered to existing sessions, spawns new sessions, or both.
POST /api/webhooks
Section titled “POST /api/webhooks”Create a new webhook.
Auth: Session cookie.
Request body:
{ "name": "Deploy on push", "source": "github", "runnerId": "runner_a1b2c3", // optional — runner to spawn sessions on "cwd": "/path/to/project", // optional "prompt": "Review the changes", // optional — initial prompt for spawned sessions "model": { "provider": "anthropic", "id": "claude-sonnet-4-20250514" }, // optional "eventFilter": ["push", "pull_request"] // optional — only fire for these event types}Response (201):
{ "webhook": { "id": "wh_uuid", "name": "Deploy on push", "source": "github", "secret": "whsec_...", "enabled": true, "runnerId": "runner_a1b2c3" }}GET /api/webhooks
Section titled “GET /api/webhooks”List all webhooks for the authenticated user.
Auth: Session cookie.
Response:
{ "webhooks": [{ "id": "wh_uuid", "name": "Deploy on push", "source": "github", "enabled": true }] }GET /api/webhooks/{id}
Section titled “GET /api/webhooks/{id}”Get details of a specific webhook.
Auth: Session cookie.
Response:
{ "webhook": { "id": "wh_uuid", "name": "...", "source": "...", "secret": "whsec_...", "enabled": true } }PUT /api/webhooks/{id}
Section titled “PUT /api/webhooks/{id}”Update a webhook’s configuration.
Auth: Session cookie.
Request body: Any subset of the creation fields (name, source, runnerId, cwd, prompt, model, eventFilter, enabled).
Response:
{ "webhook": { "id": "wh_uuid", "name": "Updated name", "enabled": true } }DELETE /api/webhooks/{id}
Section titled “DELETE /api/webhooks/{id}”Delete a webhook.
Auth: Session cookie.
Response:
{ "ok": true }POST /api/webhooks/{id}/fire
Section titled “POST /api/webhooks/{id}/fire”Fire a webhook — publishes a webhook:<slug> Event and delivers it through all matching Routes. A Route may target an existing session or spawn a new one, so a fire can reach multiple sessions. No session cookie required — authenticated via HMAC signature.
Auth: HMAC-SHA256 signature using the webhook’s secret:
- Enhanced mode (required by default, replay-protected): sign
${timestamp}.${nonce}.${rawBody}and includeX-Webhook-Timestamp(ISO string or RFC3339) andX-Webhook-Nonceheaders. The timestamp must be within a 5-minute window and the nonce must be unique per delivery. Sending only one of the two headers returns401. - Legacy mode (deprecated): sign the raw request body and send only
X-Webhook-Signature. Rejected with401unless the relay setsPIZZAPI_WEBHOOK_ALLOW_LEGACY_SIGNATURES=true; when enabled, an identical signed body is accepted at most once per webhook per 24 hours (409on repeat). Attempts that fail with403(runner unavailable),500, or503do not count, so the identical request can be retried.
Headers:
X-Webhook-Signature: <hex-encoded HMAC-SHA256>X-Webhook-Timestamp: 2025-01-15T12:00:00.000ZX-Webhook-Nonce: <unique nonce>Content-Type: application/jsonResponse:
{ "ok": true, "eventId": "evt_abc123", "sessionIds": ["session_existing", "session_spawned"], "spawnedSessions": ["session_spawned"]}sessionIds contains every delivery target; spawnedSessions contains only
the sessions created by spawn Routes.
If the event type is filtered out:
{ "ok": true, "filtered": true }A reused nonce in enhanced mode returns 409 with { "error": "Webhook nonce has already been used" }.
The webhook’s spawn config (runner/cwd/prompt/model) is synced into a
deterministic route (rt_wh_<webhookId>) on every webhook create/update/delete,
so config edits always apply. If no route accepts the delivery (e.g. the runner
rejects the spawn), the fire returns 503 — senders should retry.
Push Notifications
Section titled “Push Notifications”GET /api/push/vapid-public-key
Section titled “GET /api/push/vapid-public-key”Get the VAPID public key for Web Push subscription. No authentication required.
Response:
{ "publicKey": "BNx..." }POST /api/push/subscribe
Section titled “POST /api/push/subscribe”Subscribe the browser to push notifications.
Auth: Session cookie.
Request body:
{ "endpoint": "https://fcm.googleapis.com/fcm/send/...", "keys": { "p256dh": "base64-encoded-key", "auth": "base64-encoded-auth" }, "enabledEvents": "question,error" // optional comma-separated event list}endpoint must be an https:// URL. IPv4-literal hosts must be public unicast
addresses and localhost names are rejected (400). IPv6-literal hosts
(https://[2001:…]/…, including IPv4-mapped forms) are not supported and are
rejected with 400 and the error IPv6-literal hosts are not supported; use a DNS hostname: the pinned Bun runtime cannot verify a TLS certificate for an
IPv6-literal host. Browser push services use DNS hostnames, so this does not
affect normal subscriptions. Rows stored before this restriction are refused
at delivery time with the same reason. At delivery time the relay
resolves the endpoint host, skips delivery if any resolved address is
loopback, private, link-local, CGNAT, multicast, or otherwise non-global, and
connects only to the validated addresses (redirects are not followed).
Response:
{ "ok": true, "id": "sub_uuid" }POST /api/push/unsubscribe
Section titled “POST /api/push/unsubscribe”Unsubscribe the browser from push notifications.
Auth: Session cookie.
Request body:
{ "endpoint": "https://fcm.googleapis.com/fcm/send/..." }Response:
{ "ok": true }GET /api/push/subscriptions
Section titled “GET /api/push/subscriptions”List push subscriptions for the authenticated user.
Auth: Session cookie.
Response:
{ "subscriptions": [ { "id": "sub_uuid", "endpoint": "https://fcm.googleapis.com/...", "createdAt": "2025-01-15T12:00:00Z", "enabledEvents": "question,error", "suppressChildNotifications": false } ]}PUT /api/push/events
Section titled “PUT /api/push/events”Update which events trigger push notifications for a subscription.
Auth: Session cookie.
Request body:
{ "endpoint": "https://fcm.googleapis.com/...", "enabledEvents": "question,error,completion"}Response:
{ "ok": true }PUT /api/push/child-notifications
Section titled “PUT /api/push/child-notifications”Control whether child (sub-agent) sessions trigger push notifications.
Auth: Session cookie.
Request body:
{ "endpoint": "https://fcm.googleapis.com/...", "suppress": true}Response:
{ "ok": true }POST /api/push/answer
Section titled “POST /api/push/answer”Answer a pending agent question directly from a push notification (e.g. from mobile).
Auth: Session cookie.
Request body:
{ "sessionId": "session_x1y2z3", "toolCallId": "tc_abc123", "text": "Yes, proceed with the deployment"}Response:
{ "ok": true }Error codes:
409— No question pending, or answer already submitted403— Session not in collab mode502— Runner not connected
Settings
Section titled “Settings”GET /api/settings/hidden-models
Section titled “GET /api/settings/hidden-models”Get the list of hidden (disabled) models for the authenticated user.
Auth: Session cookie.
Response:
{ "hiddenModels": ["openai/gpt-4", "anthropic/claude-2"] }PUT /api/settings/hidden-models
Section titled “PUT /api/settings/hidden-models”Update the list of hidden models.
Auth: Session cookie.
Request body:
{ "hiddenModels": ["openai/gpt-4"] }Response:
{ "ok": true, "hiddenModels": ["openai/gpt-4"] }Tunnel (HTTP Proxy)
Section titled “Tunnel (HTTP Proxy)”The tunnel endpoints proxy HTTP requests to services running on the runner’s localhost. This allows the web UI to access dev servers, databases, or any local service through the relay.
* /api/tunnel/{sessionId}/{port}/*
Section titled “* /api/tunnel/{sessionId}/{port}/*”Session-based tunnel proxy. All HTTP methods are supported (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS).
Auth: Session cookie (must be the session owner).
The request is forwarded to 127.0.0.1:{port} on the runner. HTML, JS, and CSS responses are rewritten to route absolute paths through the tunnel prefix. Auth headers (Cookie, Authorization, x-api-key, Referer) and hop-by-hop headers (Host, Accept-Encoding, etc.) are stripped before forwarding. apiKey and tunnelToken query parameters are removed from the forwarded URL.
* /api/tunnel/runner/{runnerId}/{port}/*
Section titled “* /api/tunnel/runner/{runnerId}/{port}/*”Runner-based tunnel proxy (preferred). Same as above but addressed by runner ID instead of session ID, making the URL stable across session restarts.
Auth: Session cookie (must own the runner).
The request is forwarded to 127.0.0.1:{port} on the runner. HTML, JS, and CSS responses are rewritten to route absolute paths through the tunnel prefix. Auth headers (Cookie, Authorization, x-api-key, Referer) and hop-by-hop headers (Host, Accept-Encoding, etc.) are stripped before forwarding. apiKey and tunnelToken query parameters are removed from the forwarded URL.
A browser navigation (not an API call) to either cookie path is redirected to
the equivalent signed /api/tunnel/auth/… path below; see
Tunnel origin isolation.
POST /api/tunnel-token
Section titled “POST /api/tunnel-token”Mint a self-authenticating tunnel URL for one session or runner port. The web
UI, the mobile app, and the create_tunnel/list_tunnels tools use it so a
tunnel can load without a relay cookie. This path is not under the
/api/tunnel/ prefix.
Auth: Session cookie or API key (x-api-key header). The caller must own
the session (sessionId) or the runner (runnerId).
Request body:
{ "sessionId": "uuid", // or "runnerId": "runner-id" (one is required) "port": 3000, // integer 1–65535 "ttlHours": 24 // optional, 1–168}Response:
{ "token": "…", "expiresAt": "2026-01-01T00:00:00.000Z", "url": "/api/tunnel/auth/<token>/<scope>/3000/", "hostUrl": "https://<label>.tunnels.example.com/"}The signed token expires after one hour unless ttlHours is given. hostUrl
is present only when PIZZAPI_TUNNEL_DOMAIN is configured and Redis is
reachable; that label expires after 6 idle hours (or after ttlHours when
given). Errors: 400 (invalid JSON, missing ID, invalid port or ttlHours),
401 (not signed in), 403 (not the owner), 404 (unknown session or
runner), 405 (not POST), 503 (session has no runner).
Treat url and hostUrl as secrets: anyone holding them can reach the port
until they expire.
* /api/tunnel/auth/{token}/{scope}/{port}/*
Section titled “* /api/tunnel/auth/{token}/{scope}/{port}/*”Signed tunnel proxy. {scope} is the session ID, or runner:{runnerId} for a
runner-scoped token (URL-encoded), and must match the token together with
{port}.
Auth: The token in the path; no cookie or API key. A token is rejected
with 401 when it is invalid, expired, minted for a different scope or port,
or when its session has ended or the session/runner no longer belongs to the
user who minted it.
Forwarding, rewriting, and header stripping match the cookie paths above.
Responses keep the sandbox CSP and may be framed cross-origin: the relay
drops X-Frame-Options and removes only the frame-ancestors directive from
the app’s own Content-Security-Policy. CORS requests from the sandbox’s
null origin are answered so the app’s own fetch, module scripts, and fonts
keep working. WebSocket upgrades on this path are proxied the same way.