Nest Fastify API (project api).
Base URL: http://localhost:3000/api when served with nx serve api.
Path constants are defined in shared/api-paths.ts (API_PATHS.*, API_PATH_UPLOADS_BY_FILENAME) and used by the chat app and tests so routes stay in sync.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api | No | Returns { message: 'Hello API' } |
| GET | /api/health | No | Health check. Returns { status: 'ok' }. Use for readiness/liveness probes. |
| POST | /api/auth/login | No | Body { password? }. Returns { success, message?, token? } or 401 |
| GET | /api/messages | Bearer | Returns array of messages { id, role, body, created_at, imageUrls?, story?, activityId?, usage? }[] (story = activity timeline for assistant messages; optional usage: { inputTokens, outputTokens } is filled from the linked activity when present in activity store). |
| GET | /api/activities | Bearer | Returns array of stored activities { id, created_at, story, usage? }[] (whole story per activity; optional usage: { inputTokens, outputTokens } when the run reported token counts). |
| GET | /api/activities/by-entry/:entryId | Bearer | Returns the activity that contains the given story entry id (e.g. act-1773772973309-a3rp0me). Same response as GET /api/activities/:activityId. 404 if not found. |
| GET | /api/activities/:activityId | Bearer | Returns a single activity { id, created_at, story, usage? }. 404 if not found. |
| GET | /api/activities/:activityId/:storyId | Bearer | Returns the activity; 404 if activity not found or story entry is not in that activity. Use for deep links to a specific story within an activity. |
| GET | /api/uploads/:filename | Bearer | Serves an uploaded file (images, voice, or document attachments). |
| POST | /api/uploads | Bearer | Upload a file (multipart form field file). Returns { filename }. Allowed: images, audio, PDF, Excel, Word, text, CSV, JSON, etc. Blocked: executables and scripts. Max 20MB. |
| GET | /api/model-options | Bearer | Returns string array of model names from MODEL_OPTIONS env |
| GET | /api/playgrounds | Bearer | Returns file tree of ./playground (or PLAYGROUNDS_DIR) as JSON array |
| GET | /api/playgrounds/file | Bearer | Query path = relative path. Returns { content: string }; 404 if not found or not a file. |
| GET | /api/init-status | Bearer | Post-init script status. Returns `{ state: 'disabled' |
| POST | /api/agent/send-message | Bearer | Send a message to the agent asynchronously. Body: { text, images?, attachmentFilenames? }. Returns 202 with { accepted: true, messageId } when the message is accepted for processing. Rejects with 400 (empty text), 403 (NEED_AUTH), or 409 (AGENT_BUSY). Intended for webhooks and integrations (e.g. Sentry). |
When AGENT_PASSWORD is set, GET /api/messages, GET /api/activities, GET /api/model-options, GET /api/playgrounds, GET /api/playgrounds/file, GET /api/init-status, and POST /api/agent/send-message require Authorization: Bearer <password> or ?token=<password>.
All API logs are written as one JSON object per line to stdout/stderr so container and log aggregators can parse and filter by level, context, or request ID.
| Env var | Description |
|---|---|
DATA_DIR |
Base directory for persistence (default: ./data). When PHOENIX_AGENT_ID or CONVERSATION_ID is set, conversation data is stored under DATA_DIR/<conversation-id>/ (messages, activities, model, uploads, steering, init-status, and provider session dirs). |
POST_INIT_SCRIPT |
Optional. Shell script run once on first container load (e.g. to install tools). State is stored under the conversation data dir and exposed at GET /api/init-status. |
LOG_LEVEL |
error, warn, info (default), log, debug, verbose (case-insensitive). Minimum level emitted. info and log are equivalent. |
Log shape: { "timestamp": "<ISO8601>", "level": "log", "context": "<optional>", "message": "<string>", ... }
- Application logs:
contextis the class or module name (e.g.OrchestratorService,Credentials,Bootstrap). - HTTP requests: Each request is logged once on response with
context: "http",message: "request", plusrequestId,method,url,statusCode,durationMs. Request ID is taken fromx-request-idor generated. - WebSocket: Connection and disconnect with
context: "ws",message: "connect"or"disconnect"; each client action is logged withmessage: "action"andaction: "<action name>"(e.g.send_chat_message).
Path: /ws (same host/port as the API, no /api prefix).
Query: ?token=<password> when AGENT_PASSWORD is set.
Behaviour:
- Only one client can be the active session at a time. When a second client connects, the first client is closed with code
4002and reason "Session taken over by another client"; the second client becomes active. The displaced client can show "Your session was taken over by another client" and offer Reconnect (user clicks when they want to try again). - Unauthorized connections (wrong or missing token when password is required) are closed with code
4001.
Close codes: 4001 = Unauthorized; 4002 = Session taken over by another client (displaced client only).
| action | payload | Description |
|---|---|---|
| check_auth_status | — | Request current auth status |
| initiate_auth | — | Start provider auth flow |
| submit_auth_code | { code } |
Submit OAuth/code |
| cancel_auth | — | Cancel ongoing auth |
| reauthenticate | — | Clear credentials and re-auth |
| logout | — | Log out from provider |
| send_chat_message | { text, images?, audio?, audioFilename?, attachmentFilenames? } |
Send user message; optional images (base64), optional audio (base64), audioFilename or attachmentFilenames (from POST /api/uploads); stream response. Message text may contain @path references to playground files (e.g. @src/index.ts); the API injects those files’ contents and attached file paths into the prompt. |
| submit_story | { story } |
Submit activity story (array of { id, type, message, timestamp, details?, command?, path? }) for the last assistant message; call after stream ends. Entries with command (e.g. tool_call) or path (e.g. file_created) are shown as terminal/file blocks in the UI. |
| get_model | — | Request current model |
| set_model | { model } |
Set model name |
| interrupt_agent | — | Stop the current agent run; server sends stream_end with accumulated text so far. |
Each message is an object with a type and optional extra fields.
| type | Extra fields | Description |
|---|---|---|
| auth_status | status, isProcessing | authenticated | unauthenticated |
| auth_url_generated | url | OAuth URL to open |
| auth_device_code | code | Device code to display |
| auth_manual_token | — | Prompt for manual token (e.g. Claude) |
| auth_success | — | Auth completed |
| logout_output | text | Logout CLI output |
| logout_success | — | Logout completed |
| error | message | Error message |
| message | id?, role, body, created_at, imageUrls?, story?, model? | Persisted message (imageUrls = upload filenames; story = activity timeline for assistant messages; model = name of model that produced assistant message) |
| stream_start | model? | Start of assistant stream; optional current model name for thinking UI |
| stream_chunk | text | Chunk of assistant response |
| stream_end | usage?, model? | End of stream; optional usage: { inputTokens, outputTokens }; optional model (name of model that produced the response) for per-message display. |
| model_updated | model | Current model name |
| reasoning_start | — | Start of reasoning/thinking stream (optional) |
| reasoning_chunk | text | Chunk of reasoning text |
| reasoning_end | — | End of reasoning stream |
| thinking_step | id, title, status, details?, timestamp | Discrete thinking step (status: pending | processing | complete) |
| tool_call | name, path?, summary?, command? | Tool invocation; command is the run command text for terminal-style display. |
| file_created | name, path?, summary? | File created by agent |
The client can build a chronological story (activity timeline) from stream_start, reasoning_start / reasoning_chunk / reasoning_end, thinking_step, tool_call, and file_created events, which arrive in order during a response.
When the chat app is loaded inside an iframe (e.g. Phoenix frame), the following apply.
| Variable | Values | Description |
|---|---|---|
VITE_THEME_SOURCE |
localStorage (default), frame |
localStorage: theme from local storage + in-app toggle. frame: theme is driven by parent via postMessage (see below). |
VITE_HIDE_THEME_SWITCH |
1, true, yes |
Hide the theme toggle in the UI so the parent/iframe can control theme via postMessage. |
- Auto-auth:
{ action: 'auto_auth', password: '<internal_password>' }— chat callsPOST /api/auth/loginand stores the token. - Set theme:
{ action: 'set_theme', theme: 'light' | 'dark' }— applied whenVITE_THEME_SOURCE=frame. Theme is also persisted tolocalStorageso it survives refresh.
The backend talks to external model providers via CLI-based strategies. Two env vars control which agent is active and how it authenticates:
AGENT_PROVIDER:mock|gemini|claude-code|openai|openai-codex|opencode(defaultclaude-code)AGENT_AUTH_MODE:oauth(default) |api-token
When AGENT_AUTH_MODE=api-token, the strategies skip interactive OAuth/device flows and rely on provider API tokens from env vars:
- Claude Code:
CLAUDE_CODE_OAUTH_TOKENorANTHROPIC_API_KEY(orCLAUDE_API_KEY) - Gemini:
GEMINI_API_KEY - OpenAI Codex:
OPENAI_API_KEY - OpenCode: any of
ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY,OPENROUTER_API_KEY(existing behavior)
In api-token mode:
check_auth_statususes the presence of these env vars as the source of truth.initiate_authimmediately succeeds when the relevant env var is set; otherwise it reportsunauthenticatedwithout opening a browser.
Persistence (messages, activities, model choice, uploads, steering, init-status, and provider session state) is scoped by conversation id. This allows the same agent to always continue the same conversation after restarts.
| Env var | Description |
|---|---|
PHOENIX_AGENT_ID |
When set (e.g. by Phoenix when attaching a stored agent), used as the conversation id. All data for that agent is stored under DATA_DIR/<id>/ (id is sanitized for the filesystem). |
CONVERSATION_ID |
Fallback when PHOENIX_AGENT_ID is not set. Use for non-Phoenix deployments that want multiple conversations on the same instance. |
When neither is set, the conversation id is default, so a single DATA_DIR (or DATA_DIR/default) is used and behaviour is unchanged. When set, each conversation has its own subdirectory so the same agent id always loads the same history and provider session (e.g. Claude --continue, Gemini --resume, Codex/Opencode session dirs).