Skip to content

REST & WebSocket API

Paddock exposes a REST API under /api (packages/server/src/routes.ts) and a single WebSocket endpoint at /ws for live chat (packages/server/src/ws.ts, session-hub.ts). REST handles reads, project/chat management, and git; the actual back-and-forth of a chat turn happens over the WebSocket.

Every request passes through the auth layer (packages/server/src/auth.ts) chosen by PADDOCK_AUTH_MODE (see CONFIGURATION.md and AUTH.md):

  • In none mode (default) every request is the frozen anonymous principal — the API is fully open.
  • In trusted-header / jwt modes the proxy/IdP identity becomes req.user, and per-user read-state (unread/seen) is keyed by username.
  • GET /api/health is always exempt (liveness probe).
  • There is no per-resource authorization — chat visibility is deliberately not gated (#189). “Auth” below means “the configured mode must admit the request”.

In the tables, the Auth column is gated for the standard path and exempt for the health probe. Responses are JSON unless noted; :slug/:sessionId errors return { error, code } with 404 (not found), 409 (exists), or 400 (invalid); unexpected errors return 500.


MethodPathPurposeAuth
GET/api/healthLiveness probe → { ok: true }.exempt
GET/api/meThe authenticated principal (req.user); anonymous { username: "anonymous", anonymous: true } in none mode.gated
GET/api/modelsSelectable models + keeper/sweeper defaults + keeperDriveModeDefault.gated
GET/api/fleetFleet status + agents ({ status, agents }; error on failure).gated
GET/api/commandsSlash commands for one-off (scratch) chats.gated
MethodPathPurposeAuth
GET/api/gitBacking-store remote + GitHub connection state ({ ...remote, github }).gated
POST/api/git/pushPush the working tree to the configured remote.gated
POST/api/git/github/connectBegin the GitHub device-flow auth.gated
POST/api/git/github/pollPoll the device flow — body { deviceCode }.gated
POST/api/git/github/disconnectDisconnect GitHub → { ok: true }.gated
MethodPathPurposeAuth
GET/api/transcriptionDictation capability probe → { available, mode, model }.gated
POST/api/transcribeTranscribe a recorded audio blob (multipart file) → { text, model, mode, durationMs }. 503 if disabled, 413 oversize.gated
MethodPathPurposeAuth
GET/api/projectsList projects, each with a compact chatTurns unread signal.gated
POST/api/projectsCreate a project (+ keeper & sweeper agents) → 201 { project }.gated
GET/api/projects/:slugOne project + its changelog + chats.gated
PATCH/api/projects/:slugUpdate project metadata (model, permissionMode, maxTurns, docker, driveMode, …); re-registers the keeper. 400 on invalid field.gated
DELETE/api/projects/:slugDelete the project dir + unregister its agents → { ok, slug }.gated
MethodPathPurposeAuth
GET/api/projects/:slug/filesList the project’s freeform files.gated
GET/api/projects/:slug/files/:nameOne file + render-kind hint (markdown/html/text/image). ?raw=1 streams raw bytes (locked-down CSP).gated
GET/api/projects/:slug/changelogRaw CHANGELOG.md (text/markdown).gated
GET/api/projects/:slug/overviewRaw OVERVIEW.md (sweep-curated; "" if none).gated
GET/api/projects/:slug/commandsSlash commands for the project’s keeper agent.gated
PUT/api/projects/:slug/pinsPin a file as a sibling tab — body { file }.gated
DELETE/api/projects/:slug/pins/:fileUnpin a file (URL-encoded name).gated
GET/api/chat-files/:idRaw bytes of a file an agent shared via mcp__paddock__send_file; honors HTTP Range (206).gated
MethodPathPurposeAuth
GET/api/projects/:slug/git/statusUncommitted changes in the project subtree ({ repo: false } if not a work tree).gated
GET/api/projects/:slug/git/diffUnified diff (working tree vs HEAD), or one file via ?file= (text/plain).gated
POST/api/projects/:slug/git/commitCommit the project’s pending changes — body { message? }.gated
MethodPathPurposeAuth
GET/api/projects/:slug/chatsList the project’s chats (no usage rings).gated
POST/api/projects/:slug/chatsThin: validate the project + return the WS target → 201. The real chat is created lazily over /ws.gated
GET/api/projects/:slug/chats/usageBulk context-window usage for all chats, keyed by session id.gated
GET/api/projects/:slug/chats/:sessionId/messagesA chat’s messages, enriched with tool details.gated
GET/api/projects/:slug/chats/:sessionId/contextContext-window usage from the transcript’s last turn.gated
GET/api/projects/:slug/chats/:sessionId/subagents/:toolUseId/messagesTranscript of a sub-agent launched from a Task/Agent tool block.gated
PATCH/api/projects/:slug/chats/:sessionIdRename (or clear name) — body { name? }.gated
DELETE/api/projects/:slug/chats/:sessionIdDelete a chat (removes its transcript + attachments).gated
POST/api/projects/:slug/chats/:sessionId/forkFork a chat into a new resumable session — body { name? }201 { sessionId }.gated
POST/api/projects/:slug/chats/:sessionId/archiveArchive/unarchive (non-destructive) — body { archived? }.gated
POST/api/projects/:slug/chats/:sessionId/seenMark seen (server-side read-state) — body { when? }.gated
MethodPathPurposeAuth
GET/api/chatsList one-off (scratch) chats.gated
GET/api/chats/:sessionId/messagesA scratch chat’s messages (enriched).gated
GET/api/chats/:sessionId/contextContext-window usage for a scratch chat.gated
GET/api/chats/:sessionId/subagents/:toolUseId/messagesSub-agent transcript within a scratch chat.gated
PATCH/api/chats/:sessionIdRename a scratch chat — body { name? }.gated
DELETE/api/chats/:sessionIdDelete a scratch chat.gated
POST/api/chats/:sessionId/archiveArchive/unarchive a scratch chat — body { archived? }.gated
POST/api/chats/:sessionId/seenMark a scratch chat seen — body { when? }.gated
POST/api/chats/:sessionId/promotePromote a scratch chat into a new project (re-homes its transcript) — body { name, slug?, group?, summary?, domain? }201 { project, promoted, sessionId }.gated

The chat back-and-forth runs over a single WebSocket at /ws, registered behind the same auth hook as REST. The web client opens one shared socket (packages/web/src/lib/ws.ts), auto-reconnects, and keeps it alive with a JSON ping/pong every 25s (plus protocol-level WS pings server-side).

Every frame is a JSON object { type, payload } (ping/pong are just { type }, no payload). type is the message kind.

Server→client chat events carry a common Routing block in payload:

FieldTypeNotes
projectSlugstringProject slug, or "scratch" for one-off chats.
targetstringLegacy alias for projectSlug (server emits both).
sessionIdstring | nullNull until a brand-new chat’s id first streams back.
jobIdstring | nullThe cancellable job id, when known.
seqnumber?Per-turn monotonic sequence for reconnect/gap-replay. Absent on frames not routed through the hub (chat:error, chat:resync, chat:active, chat:queued_flushed, pong).

Client→server payloads accept either projectSlug or the legacy target alias. Invalid JSON / unknown kinds get a chat:error reply.

KindWhen it firesPayload (beyond projectSlug/target)
chat:subscribeOn (re)connect, to attach a socket to a session’s live stream and replay any missed gap.sessionId: string, wantReplay?: boolean, lastSeq?: number
chat:sendUser (or a server-side queue drain) sends a message / starts or resumes a turn.sessionId?: string | null (null ⇒ new chat), message: string, preloadContext?: boolean, model?: string
chat:commandUser runs a slash command (e.g. /compact) in the current chat.sessionId?: string | null, command: string (full text incl. leading slash)
chat:cancelUser clicks Stop; cancels the running turn’s job.jobId: string
chat:set_queuePersist/clear the single-slot composer queue server-side (survives browser close).sessionId?: string | null, text?: string | null (null/empty ⇒ clear), ts?: number | null
pingClient keepalive every 25s.(none)
KindWhen it firesPayload (beyond Routing)
chat:activeA session’s live-turn status changed (start/stop); broadcast to all clients, and sent as a snapshot to a newly-connected or subscribing socket.sessionId: string, jobId: string | null, running: boolean (this frame carries its own projectSlug/target/sessionId, no seq)
chat:responseA streamed assistant text delta. Also surfaces a /compact boundary as a synthetic note.chunk: string
chat:tool_startA tool_use begins (before it runs) — renders a pending “running…” row.toolName: string, inputSummary?: string, toolUseId?: string, parentToolUseId: string | null
chat:tool_callA tool completes (paired tool_use→tool_result); reconciles the pending row.toolName: string, inputSummary?: string, output: string, isError: boolean, durationMs?: number, toolUseId?: string
chat:message_boundaryAn assistant message bubble ended.(Routing only)
chat:completeThe turn finished (success or failure); carries final usage/model.success: boolean, error?: string, model?: string, usage?: ChatCompleteUsage
chat:errorA turn threw before/without a resolved session (sent to the origin socket only); also the reply to invalid JSON / unknown frames.projectSlug: string, target: string, error: string (no sessionId/jobId/seq)
chat:resyncReconnect fallback: the live turn’s frame buffer aged out past the requested gap, so the client must re-hydrate from the transcript.projectSlug: string, target: string, sessionId: string
chat:queued_flushedThe server auto-drained the persisted queued message after a turn (or when idle).projectSlug: string, target: string, sessionId: string, text?: string (present ⇒ render as a user bubble; absent ⇒ just clear a stale copy)
pongReply to a client ping.(none)

ChatCompleteUsage (on chat:complete): inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, contextTokens (= input + cacheRead + cacheCreation), contextLimit (= the model’s context limit). Stale-by-one-turn by design.

Notes: There is no chat:tool_end (completion is chat:tool_call), no chat:queued (drain is chat:queued_flushed), and no dedicated snapshot frame — chat:active doubles as the on-connect snapshot, and reconnect/replay flows through chat:subscribe → (replay | chat:resync). A /compact compaction is folded into a chat:response chunk + chat:message_boundary, not its own kind.