Skip to content

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.


Pass your API key in the x-api-key header:

x-api-key: <64-character hex key>

The web UI uses cookie-based sessions managed by better-auth. Log in via POST /api/auth/sign-in/email.


Returns server status. No authentication required.

GET /health

Response:

{
"status": "ok",
"redis": true,
"socketio": true,
"uptime": 1234,
"version": {
"server": "0.80.3",
"socketProtocol": 1,
"buildTimestamp": "2026-07-09T00:00:00.000Z"
}
}

Authentication is handled by better-auth at the /api/auth/* prefix. See the better-auth documentation for the full list of endpoints.

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).


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": []
}
]
}

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.


Restart the runner daemon (and all sessions running on it).

Auth: Session cookie.

Request body:

{
"runnerId": "runner_a1b2c3"
}

Response:

{ "ok": true }

Stop (shut down) a runner daemon.

Auth: Session cookie.

Request body:

{
"runnerId": "runner_a1b2c3"
}

Response:

{ "ok": true }

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 }

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" }
]
}

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": []
}

List available skills on a runner (from Redis cache).

Auth: Session cookie.

Response:

{
"skills": ["deploy", "review-pr", "run-tests"]
}

Get the full content of a specific skill.

Auth: Session cookie.

Response:

{ "name": "deploy", "content": "# Deploy Skill\n..." }

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"] }

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"] }

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" }
]
}

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"]
}

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 (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 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": []
}

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 }

List available agent definitions on a runner (from Redis cache).

Auth: Session cookie.

Response:

{ "agents": ["reviewer", "deployer"] }

Get the full content of a specific agent definition.

Auth: Session cookie.

Response:

{ "name": "reviewer", "content": "# Agent definition..." }

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"] }

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"] }

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": [] }

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"
}
]
}

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 type
  • Content-Disposition — inline; filename="..."
  • X-Attachment-Id — the attachment ID
  • X-Attachment-Filename — percent-encoded original filename

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"
}
]
}

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": []
}

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.


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 to delivered on the ack, or back to pending on 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 answered
  • escalated — unanswered, re-routed up the escalation chain
  • expired — TTL lapsed with no answer / no recipient

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 }

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 }

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.


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.


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 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.

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"
}
}

List all webhooks for the authenticated user.

Auth: Session cookie.

Response:

{ "webhooks": [{ "id": "wh_uuid", "name": "Deploy on push", "source": "github", "enabled": true }] }

Get details of a specific webhook.

Auth: Session cookie.

Response:

{ "webhook": { "id": "wh_uuid", "name": "...", "source": "...", "secret": "whsec_...", "enabled": true } }

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 a webhook.

Auth: Session cookie.

Response:

{ "ok": true }

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 include X-Webhook-Timestamp (ISO string or RFC3339) and X-Webhook-Nonce headers. The timestamp must be within a 5-minute window and the nonce must be unique per delivery. Sending only one of the two headers returns 401.
  • Legacy mode (deprecated): sign the raw request body and send only X-Webhook-Signature. Rejected with 401 unless the relay sets PIZZAPI_WEBHOOK_ALLOW_LEGACY_SIGNATURES=true; when enabled, an identical signed body is accepted at most once per webhook per 24 hours (409 on repeat). Attempts that fail with 403 (runner unavailable), 500, or 503 do 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.000Z
X-Webhook-Nonce: <unique nonce>
Content-Type: application/json

Response:

{
"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.


Get the VAPID public key for Web Push subscription. No authentication required.

Response:

{ "publicKey": "BNx..." }

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" }

Unsubscribe the browser from push notifications.

Auth: Session cookie.

Request body:

{ "endpoint": "https://fcm.googleapis.com/fcm/send/..." }

Response:

{ "ok": true }

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
}
]
}

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 }

Control whether child (sub-agent) sessions trigger push notifications.

Auth: Session cookie.

Request body:

{
"endpoint": "https://fcm.googleapis.com/...",
"suppress": true
}

Response:

{ "ok": true }

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 submitted
  • 403 — Session not in collab mode
  • 502 — Runner not connected

Get the list of hidden (disabled) models for the authenticated user.

Auth: Session cookie.

Response:

{ "hiddenModels": ["openai/gpt-4", "anthropic/claude-2"] }

Update the list of hidden models.

Auth: Session cookie.

Request body:

{ "hiddenModels": ["openai/gpt-4"] }

Response:

{ "ok": true, "hiddenModels": ["openai/gpt-4"] }

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.

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.


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.


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.