# REST API Contract

Base URL: `/api` behind web proxy, or server root directly in local/dev.

Authentication:
- If `ADMIN_TOKEN` is set on server, send `Authorization: Bearer <token>`.
- If `ADMIN_TOKEN` is unset, API is open for local/dev use.

Content type:
- Requests with bodies use `application/json`.
- Responses are JSON unless noted otherwise.

Chat upload limits:
- Chat completion and direct message payloads support inline attachments up to a 32 MB request body.
- Up to 8 attachments per message.
- Image attachments: PNG or JPEG only, max 6 MB each.
- Text attachments: up to 8 MB source size each; server accepts at most 200,000 characters of inlined text content per attachment.

## Health + Auth

### `GET /health`
- Response: `{ "ok": true }`

### `GET /v1/auth/session`
- Response: `{ "authenticated": true, "mode": "open" | "token" }`

## Models

### `GET /v1/models`
- Response:
```json
{
  "providers": {
    "openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
    "anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
    "xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
    "gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
    "hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
  }
}
```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.

## Chat Tools

### `GET /v1/chat-tools`
- Response:
```json
{
  "tools": [
    { "name": "web_search", "description": "..." },
    { "name": "fetch_url", "description": "..." }
  ]
}
```

Behavior notes:
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.

## Active Runs

### `GET /v1/active-runs`
- Response:
```json
{
  "chats": ["chat-id-with-active-stream"],
  "searches": ["search-id-with-active-stream"]
}
```

Behavior notes:
- Lists in-memory chat/search streams that are still running on this server process.
- Clients should use this after app start or page refresh to restore per-row generating indicators.
- The lists are not durable across server restarts.

## Workspace Items

### `GET /v1/workspace-items`
- Response: `{ "items": WorkspaceItem[] }`
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
```json
{
  "items": [
    {
      "type": "chat",
      "id": "chat-id",
      "title": "optional title",
      "parentChatId": null,
      "titleGenerationPending": false,
      "createdAt": "2026-02-14T00:00:00.000Z",
      "updatedAt": "2026-02-14T00:00:00.000Z",
      "starred": true,
      "starredAt": "2026-02-14T01:00:00.000Z",
      "initiatedProvider": "openai",
      "initiatedModel": "gpt-4.1-mini",
      "lastUsedProvider": "openai",
      "lastUsedModel": "gpt-4.1-mini",
      "additionalSystemPrompt": null,
      "enabledTools": ["web_search", "fetch_url"]
    },
    {
      "type": "search",
      "id": "search-id",
      "title": "optional title",
      "query": "search query",
      "createdAt": "2026-02-14T00:00:00.000Z",
      "updatedAt": "2026-02-14T00:00:00.000Z",
      "starred": false,
      "starredAt": null
    }
  ]
}
```

Behavior notes:
- This endpoint is intended for combined conversation/search lists such as sidebars.
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.

## Chats

### `GET /v1/chats`
- Response: `{ "chats": ChatSummary[] }`

### `POST /v1/chats`
- Body:
```json
{
  "title": "optional title",
  "provider": "optional openai|anthropic|xai|gemini|hermes-agent",
  "model": "optional model id",
  "additionalSystemPrompt": "optional stored system prompt",
  "enabledTools": ["web_search", "fetch_url"],
  "messages": [
    {
      "role": "system|user|assistant|tool",
      "content": "string",
      "name": "optional",
      "attachments": []
    }
  ]
}
```
- Response: `{ "chat": ChatSummary }`

Behavior notes:
- `provider` and `model` must be supplied together when present.
- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`.
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.

### `POST /v1/chats/:chatId/fork`
- Body: `{ "messageId"?: string }`
- Response: `{ "chat": ChatSummary }`
- Source chat not found: `404 { "message": "chat not found" }`
- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }`
- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }`

Behavior notes:
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once.

### `PATCH /v1/chats/:chatId`
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
- Response: `{ "chat": ChatSummary }`
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title.
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
- Updating chat fields changes the returned chat's `updatedAt`.
- Not found: `404 { "message": "chat not found" }`

### `PATCH /v1/chats/:chatId/star`
- Body: `{ "starred": boolean }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "chat not found" }`

Behavior notes:
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
- This does not modify the chat transcript or chat `updatedAt`.

### `POST /v1/chats/title/suggest`
- Body:
```json
{
  "chatId": "chat-id",
  "content": "user request text"
}
```
- Response: `{ "chat": ChatSummary }`

Behavior notes:
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.

### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }`
- Not found: `404 { "message": "chat not found" }`
- Active chat or fork: `409 { "message": "chat or fork has an active stream" }`
- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }`
- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged.
- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active.

### `GET /v1/chats/:chatId`
- Response: `{ "chat": ChatDetail }`

### `POST /v1/chats/:chatId/messages`
- Body:
```json
{
  "role": "system|user|assistant|tool",
  "content": "string",
  "name": "optional",
  "metadata": {},
  "attachments": [
    {
      "kind": "image",
      "id": "attachment-id",
      "filename": "photo.jpg",
      "mimeType": "image/jpeg",
      "sizeBytes": 12345,
      "dataUrl": "data:image/jpeg;base64,..."
    },
    {
      "kind": "text",
      "id": "attachment-id",
      "filename": "notes.md",
      "mimeType": "text/markdown",
      "sizeBytes": 4567,
      "text": "# Notes\\n...",
      "truncated": false
    }
  ]
}
```
- Response: `{ "message": Message }`

Notes:
- `attachments` is optional and is merged into stored `message.metadata.attachments`.
- Tool messages should not include attachments.

## Chat Completions (non-streaming)

### `POST /v1/chat-completions`
- Body:
```json
{
  "chatId": "optional-chat-id",
  "provider": "openai|anthropic|xai|gemini|hermes-agent",
  "model": "string",
  "messages": [
    {
      "role": "system|user|assistant|tool",
      "content": "string",
      "name": "optional",
      "attachments": [
        {
          "kind": "image",
          "id": "attachment-id",
          "filename": "photo.jpg",
          "mimeType": "image/jpeg",
          "sizeBytes": 12345,
          "dataUrl": "data:image/jpeg;base64,..."
        },
        {
          "kind": "text",
          "id": "attachment-id",
          "filename": "notes.md",
          "mimeType": "text/markdown",
          "sizeBytes": 4567,
          "text": "# Notes\\n...",
          "truncated": false
        }
      ]
    }
  ],
  "additionalSystemPrompt": "optional one-off system prompt",
  "enabledTools": ["web_search", "fetch_url"],
  "temperature": 0.2,
  "maxTokens": 256
}
```
- Response:
```json
{
  "chatId": "chat-id-or-null",
  "provider": "openai",
  "model": "gpt-4.1-mini",
  "message": { "role": "assistant", "content": "..." },
  "usage": { "inputTokens": 10, "outputTokens": 20, "totalTokens": 30 },
  "raw": {}
}
```

Behavior notes:
- If `chatId` is present, server validates chat existence.
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
- Images are forwarded inline to providers as multimodal image parts. Use PNG or JPEG for cross-provider compatibility.
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
- For `gemini`, backend calls Google's native Gemini `generateContent` API and enables internal tool use with Gemini function calling.
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
- For `gemini`, image attachments are sent as native Gemini `inlineData` parts and text attachments are sent as text parts.
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
- Devbox tool configuration:
  - `CHAT_MAX_TOOL_ROUNDS=100` (optional; maximum model/tool result cycles before the backend returns a limit message)
  - `CHAT_CODEX_TOOL_ENABLED=true`
  - `CHAT_SHELL_TOOL_ENABLED=true`
  - `CHAT_CODEX_REMOTE_HOST=<host-or-ip>` (required when enabled)
  - `CHAT_CODEX_REMOTE_USER=<ssh-user>` (optional; omitted if `CHAT_CODEX_REMOTE_HOST` already contains `user@host`)
  - `CHAT_CODEX_REMOTE_PORT=22` (optional)
  - `CHAT_CODEX_REMOTE_WORKDIR=/workspace/sybil-codex` (optional; created on the remote host if missing)
  - `CHAT_CODEX_SSH_KEY_PATH=/run/secrets/codex_ssh_key` (recommended private-key delivery via read-only volume mount)
  - `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
  - `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
  - `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.

## Streaming Chat

### `POST /v1/chat-completions/stream`

- The request accepts the chat-completion fields above plus optional `persist` and `clientRequestId` fields.
- `clientRequestId` is only valid for a persisted request with a `chatId`, may be up to 128 characters, and should be a stable unique value generated once per user submission.
- Retrying with the same `chatId` and `clientRequestId` replays the matching active or completed stream rather than starting a duplicate provider call.
- The server persists the ID in `metadata.clientRequestId` on the submitted user message and completed assistant message.
- The complete request, SSE event, persistence, retry, and attach contracts are defined in `docs/api/streaming-chat.md`.

## Searches

### `GET /v1/searches`
- Response: `{ "searches": SearchSummary[] }`

### `POST /v1/searches`
- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }`
- Response: `{ "search": SearchSummary, "reused": boolean, "cacheHit": boolean }`

Behavior notes:
- `reuseByQuery` defaults to `false`, preserving the normal create-a-new-search behavior.
- When `reuseByQuery` is `true` and `query` is present, the backend normalizes the query with `trim().toLowerCase()` and returns the most recently updated existing search with that normalized query instead of creating a duplicate.
- `cacheHit` is `true` only when the reused search has persisted results or answer text, is not currently streaming, and was updated within the 24-hour search cache window. Clients can then fetch `GET /v1/searches/:searchId` and display it without running another search.
- If a matching search exists but `cacheHit` is `false`, clients may run the search again on the returned `search.id`; the run endpoints replace that search's persisted results and answer with the latest run.

### `PATCH /v1/searches/:searchId/star`
- Body: `{ "starred": boolean }`
- Response: `{ "search": SearchSummary }`
- Not found: `404 { "message": "search not found" }`

Behavior notes:
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
- This does not modify the search results or search `updatedAt`.

### `DELETE /v1/searches/:searchId`
- Response: `{ "deleted": true }`
- Not found: `404 { "message": "search not found" }`

### `GET /v1/searches/:searchId`
- Response: `{ "search": SearchDetail }`

### `POST /v1/searches/:searchId/chat`
- Body: `{ "title"?: string }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "search not found" }`

Behavior notes:
- Creates a new chat seeded with a hidden `system` message containing the search query, answer text, answer citations, and top search results.
- Clients should include existing `system` messages when sending the chat history to `/v1/chat-completions` or `/v1/chat-completions/stream`; they may hide those messages in the transcript UI.
- The default chat title is `Search: <query-or-title>`, unless `title` is supplied.

### `POST /v1/searches/:searchId/run`
- Body:
```json
{
  "query": "optional override",
  "title": "optional override",
  "type": "auto|fast|deep|instant",
  "numResults": 10,
  "includeDomains": ["example.com"],
  "excludeDomains": ["example.org"]
}
```
- Response: `{ "search": SearchDetail }`

Search run notes:
- Backend executes Exa search and Exa answer.
- Search mode is independent from chat `web_search` tool configuration and remains Exa-only.
- Persists answer text/citations + ranked results.
- If both search and answer fail, endpoint returns an error.

### `POST /v1/searches/:searchId/run/stream`
- Body: same as `POST /v1/searches/:searchId/run`
- Response: `text/event-stream`

Events:
- `search_results`: `{ "requestId": string|null, "results": SearchResultItem[] }`
- `search_error`: `{ "error": string }`
- `answer`: `{ "answerText": string|null, "answerRequestId": string|null, "answerCitations": SearchDetail["answerCitations"] }`
- `answer_error`: `{ "error": string }`
- terminal `done`: `{ "search": SearchDetail }`
- terminal `error`: `{ "message": string }`

Behavior notes:
- The stream is owned by the backend after it starts. If the original HTTP client disconnects, the backend keeps running and persists the final search state.
- While a search stream is active, `GET /v1/active-runs` includes the `searchId`.
- If a stream is already active for the same `searchId`, this endpoint attaches to the existing stream instead of starting a second run.

### `POST /v1/searches/:searchId/run/stream/attach`
- Body: none
- Response: `text/event-stream` with the same event names as `POST /v1/searches/:searchId/run/stream`
- Not found: `404 { "message": "active search stream not found" }`

Behavior notes:
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
- Intended for clients that discovered a pending search via `GET /v1/active-runs`, such as after browser refresh.

## Type Shapes

`ChatSummary`
```json
{
  "id": "...",
  "title": null,
  "parentChatId": null,
  "titleGenerationPending": false,
  "createdAt": "...",
  "updatedAt": "...",
  "starred": false,
  "starredAt": null,
  "initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
  "initiatedModel": "string|null",
  "lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
  "lastUsedModel": "string|null",
  "additionalSystemPrompt": null,
  "enabledTools": ["web_search", "fetch_url"]
}
```

`Message`
```json
{
  "id": "...",
  "createdAt": "...",
  "role": "system|user|assistant|tool",
  "content": "...",
  "name": null,
  "metadata": {
    "attachments": [
      {
        "kind": "image",
        "id": "attachment-id",
        "filename": "photo.jpg",
        "mimeType": "image/jpeg",
        "sizeBytes": 12345,
        "dataUrl": "data:image/jpeg;base64,..."
      },
      {
        "kind": "text",
        "id": "attachment-id",
        "filename": "notes.md",
        "mimeType": "text/markdown",
        "sizeBytes": 4567,
        "text": "# Notes\\n...",
        "truncated": false
      }
    ]
  }
}
```

`metadata` remains nullable. Tool-call log messages still use `metadata.kind = "tool_call"`; regular user messages with attachments use `metadata.attachments`.

`ChatDetail`
```json
{
  "id": "...",
  "title": null,
  "parentChatId": null,
  "titleGenerationPending": false,
  "createdAt": "...",
  "updatedAt": "...",
  "starred": false,
  "starredAt": null,
  "initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
  "initiatedModel": "string|null",
  "lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
  "lastUsedModel": "string|null",
  "additionalSystemPrompt": null,
  "enabledTools": ["web_search", "fetch_url"],
  "messages": [Message]
}
```

`SearchSummary`
```json
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
```

`SearchDetail`
```json
{
  "id": "...",
  "title": "...",
  "query": "...",
  "createdAt": "...",
  "updatedAt": "...",
  "starred": false,
  "starredAt": null,
  "requestId": "...",
  "latencyMs": 123,
  "error": null,
  "answerText": "...",
  "answerRequestId": "...",
  "answerCitations": [],
  "answerError": null,
  "results": []
}
```

For streaming contracts, see `docs/api/streaming-chat.md`.
