Changeset 0.28.0.1 (#173)

This commit is contained in:
2026-03-11 14:45:37 +00:00
parent 93c72daadf
commit 58313f7e31
57 changed files with 5139 additions and 3206 deletions

155
docs/ICD/README.md Normal file
View File

@@ -0,0 +1,155 @@
# ICD-API — Chat Switchboard Backend API Contract
**Version:** 0.28.0
**Updated:** 2026-03-11
**Audience:** Frontend developers, integrators, API consumers
> **Organization principle:** Each file is one domain — the complete story.
> Every endpoint that touches Knowledge Bases is in `knowledge.md`, every
> endpoint that touches Personas is in `personas.md`, etc.
## Files
| File | Domain | Endpoints |
|------|--------|-----------|
| [auth.md](auth.md) | Auth — login, register, refresh, OIDC, mTLS | 6 |
| [channels.md](channels.md) | Channels, messages, completions, participants, folders, presence | ~35 |
| [personas.md](personas.md) | Personas, persona groups, avatars, KB bindings, tool grants | ~20 |
| [knowledge.md](knowledge.md) | Knowledge bases, documents, search, channel/persona bindings | ~12 |
| [notes.md](notes.md) | Notes, wikilinks, graph, search, folders | ~10 |
| [workspaces.md](workspaces.md) | Workspaces, file ops, git, credentials | ~20 |
| [projects.md](projects.md) | Projects, channel/KB/note associations | ~14 |
| [memory.md](memory.md) | Memory extraction, review, admin | 7 |
| [providers.md](providers.md) | Provider configs, health, capabilities, routing policies | ~25 |
| [models.md](models.md) | Enabled models, user preferences | 4 |
| [notifications.md](notifications.md) | Notifications, preferences | 7 |
| [extensions.md](extensions.md) | Extensions, user settings, admin | 8 |
| [profile.md](profile.md) | User profile, avatar, password, app settings | 7 |
| [teams.md](teams.md) | Teams, members, groups, permissions, grants | ~25 |
| [admin.md](admin.md) | Platform admin — users, settings, stats, audit, usage, vault | ~25 |
| [utility.md](utility.md) | Health, export, files, storage | ~12 |
| [workflows.md](workflows.md) | Workflow definitions, stages, instances, assignments, entry | ~20 |
| [tasks.md](tasks.md) | Task definitions, runs, scheduler, team tasks | ~14 |
| [surfaces.md](surfaces.md) | Surface registry, extension surfaces | 6 |
| [websocket.md](websocket.md) | WebSocket protocol, events, rooms | — |
| [enums.md](enums.md) | All enum values, policies | — |
## Conventions
### Base URL
```
{scheme}://{host}{BASE_PATH}/api/v1
```
`BASE_PATH` is empty by default. In path-routed K8s deployments (e.g.
`/staging/`), all routes shift accordingly. The frontend reads `BASE_PATH`
from a `<meta>` tag injected by the Go template engine.
### Authentication
JWT bearer tokens. Every request to a `protected` or `admin` route requires:
```
Authorization: Bearer {access_token}
```
**Access token:** HS256 JWT, 15-minute TTL. Claims: `user_id`, `email`,
`role`, `exp`, `iat`, `jti`.
**Refresh token:** Opaque, DB-stored, 7-day TTL. Used to obtain new
access tokens without re-login.
**WebSocket fallback:** `?token={access_token}` query parameter when
the `Authorization` header isn't available.
**Cookie sync:** On every token save/refresh, the frontend writes
`sb_token` as a cookie. Go template page routes read this cookie via
`AuthOrRedirect` middleware for server-rendered surfaces.
### Auth Modes
| Mode | `AUTH_MODE` | How it works |
|------|------------|--------------|
| Builtin | `builtin` (default) | Username/password, bcrypt, JWT |
| mTLS | `mtls` | Reverse proxy cert headers, auto-provision |
| OIDC | `oidc` | Authorization code flow (Keycloak etc.) |
All three modes issue the same JWT after authentication. Downstream
middleware is auth-mode-agnostic.
### Authorization Tiers
| Tier | Middleware | Who |
|------|-----------|-----|
| Public | none | Anyone (health, login, register, public settings) |
| Session | `AuthOrSession()` | JWT user OR anonymous session cookie (workflow visitors) |
| Authenticated | `Auth()` | Any logged-in user |
| Permission | `RequirePermission(perm)` | User whose groups grant `perm` |
| Team Admin | `RequireTeamAdmin()` | Admin of the specific team |
| Platform Admin | `RequireAdmin()` | Users with `role = "admin"` |
### Error Envelope
All errors return:
```json
{ "error": "human-readable message" }
```
Standard HTTP status codes: 400 (bad request), 401 (not authenticated),
403 (not authorized), 404 (not found), 409 (conflict), 500 (server error),
502 (upstream provider failure).
### Pagination Envelope
Endpoints that paginate return:
```json
{
"data": [...],
"page": 1,
"per_page": 50,
"total": 142,
"total_pages": 3
}
```
Query params: `?page=1&per_page=50`. Not all list endpoints paginate —
some return `{ "data": [...] }` or a bare array in a named key.
### ID Format
All resource IDs are UUIDv4 strings, generated application-side via
`store.NewID()` (Go `uuid.New()`). This ensures compatibility across
both Postgres and SQLite backends.
### Timestamps
ISO 8601 with timezone: `"2025-06-15T14:30:00Z"`. Stored as
`TIMESTAMPTZ` (Postgres) or `TEXT` (SQLite).
## Page Routes (Non-API)
Server-rendered Go template surfaces. Not REST endpoints — return HTML.
| Route | Surface | Description |
|-------|---------|-------------|
| `/login` | Login | Standalone login/register page |
| `/` | Chat | Main chat interface |
| `/chat/:chatID` | Chat | Chat with specific channel loaded |
| `/editor` | Editor | Workspace file editor |
| `/editor/:wsId` | Editor | Editor with specific workspace |
| `/notes` | Notes | Notes interface |
| `/notes/:noteId` | Notes | Notes with specific note loaded |
| `/admin` | Admin | Platform administration |
| `/admin/:section` | Admin | Admin with specific section |
| `/settings` | Settings | User settings |
| `/settings/:section` | Settings | Settings with specific section |
| `/w/:id` | Workflow Landing | Branded workflow entry page |
| `/w/:id/:slug` | Workflow Landing | Slug-suffixed workflow entry |
| `/s/:slug` | Extension Surface | Dynamic surface from registry |
All page routes (except `/login`, `/w/`) require authentication via
`AuthOrRedirect` middleware. Admin routes additionally require
`RequireAdminPage()`. Workflow landing pages use `AuthOrSession`.

198
docs/ICD/admin.md Normal file
View File

@@ -0,0 +1,198 @@
# Platform Administration
Cross-cutting admin operations that don't belong to a specific domain.
### User Management
```
GET /admin/users → { "users": [...] }
POST /admin/users ← { "username", "password", "email", "role" }
PUT /admin/users/:id/role ← { "role": "admin|user" }
PUT /admin/users/:id/active ← { "active": false }
DELETE /admin/users/:id
POST /admin/users/:id/reset-password ← { "new_password": "..." }
POST /admin/users/:id/vault/reset → destroys user's UEK (BYOK keys lost)
```
### Global Settings & Policies
Settings are key-value pairs in `global_settings`. Policies are
boolean flags that gate features.
```
GET /admin/settings → all settings
GET /admin/settings/:key → single setting
PUT /admin/settings/:key ← { "value": ... }
```
The handler auto-detects: if the value is a string and the key is a
known policy name, it writes to the `policies` table. Otherwise it
writes to `global_config` as JSON.
**Public settings** (non-admin, for FE bootstrapping):
```
GET /settings/public
```
```json
{
"banner": { "enabled": true, "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff", "position": "both" },
"branding": { "instance_name": "Switchboard", "logo_url": "...", "tagline": "..." },
"has_admin_prompt": true,
"storage_configured": true,
"paste_to_file_chars": 2000,
"policies": {
"allow_registration": "true",
"allow_user_byok": "true",
"allow_user_personas": "true"
}
}
```
### Banner Configuration
The environment banner is stored as a single `global_config` entry
under the key `"banner"`. It's a simple object:
```json
{
"enabled": true,
"text": "DEVELOPMENT",
"position": "both|top|bottom",
"bg": "#007a33",
"fg": "#ffffff"
}
```
Set via `PUT /admin/settings/banner` with `{ "value": { ... } }`.
The admin UI provides a color picker, position selector, and preset
dropdown. The Go template base layout reads the banner from
`PageData` and renders top/bottom strips with CSS custom properties.
### Platform Stats
```
GET /admin/stats
```
```json
{
"users": 42,
"channels": 156,
"messages": 12847
}
```
### Storage & Vault
**Storage status:**
```
GET /admin/storage/status → { "backend", "healthy", "file_count", "total_bytes", ... }
GET /admin/storage/orphans → { "count": 3 }
POST /admin/storage/cleanup → removes orphaned blobs
GET /admin/storage/extraction → extraction queue status
```
**Vault:**
```
GET /admin/vault/status → { "encryption_key_set", "user_vaults_count", ... }
```
### Audit Log
```
GET /admin/audit?page=1&per_page=50&action=user.create&actor_id=uuid
GET /admin/audit/actions → { "actions": ["user.create", "policy.update", ...] }
```
Audit entry:
```json
{
"id": "uuid",
"actor_id": "uuid",
"action": "user.create",
"resource_type": "user",
"resource_id": "uuid",
"metadata": {},
"created_at": "..."
}
```
### Usage & Pricing
```
GET /admin/usage → { "totals", "results" }
GET /admin/usage/teams/:id → team-specific usage
GET /admin/usage/users/:id → user-specific usage
GET /admin/pricing → { "data": [...] }
PUT /admin/pricing ← { "provider", "model", "input_per_m", "output_per_m" }
DELETE /admin/pricing/:provider/:model
```
**Personal usage** (non-admin):
```
GET /usage → { "totals", "results" }
```
Scoped to the user's BYOK provider usage only.
### Roles (Platform)
```
GET /admin/roles → { "roles": [...] }
GET /admin/roles/:role
PUT /admin/roles/:role ← { "permissions": {...} }
POST /admin/roles/:role/test ← test role permissions
```
### Email Test
```
POST /admin/notifications/test-email
```
```json
{ "to": "admin@example.com" }
```
Sends a test email using the configured SMTP settings.
---
### Archived Channels
```
GET /admin/channels/archived → { "data": [...] }
DELETE /admin/channels/:id/purge → permanent delete (ignores retention)
```
**Auth:** Platform admin.
### Surfaces Admin
See [surfaces.md](surfaces.md) for the full surface management API.
```
GET /admin/surfaces → all surfaces (including disabled)
GET /admin/surfaces/:id → surface with manifest
POST /admin/surfaces/install ← multipart .surface archive
PUT /admin/surfaces/:id/enable
PUT /admin/surfaces/:id/disable
DELETE /admin/surfaces/:id
```
### Tasks Admin
See [tasks.md](tasks.md) for the full task management API.
```
GET /admin/tasks → all tasks across all users/teams
POST /admin/tasks/:id/run → force run
POST /admin/tasks/:id/kill → cancel active run
DELETE /admin/tasks/:id → delete task
```

175
docs/ICD/auth.md Normal file
View File

@@ -0,0 +1,175 @@
# Auth
Three auth modes, configured via `AUTH_MODE` env var. All three issue the
same JWT after authentication — downstream middleware is auth-mode-agnostic.
## Builtin Auth (default)
Username/password authentication with bcrypt hashing.
### Register
```
POST /auth/register
```
```json
{
"username": "jdoe",
"password": "...",
"email": "jdoe@example.com",
"display_name": "Jane Doe"
}
```
Gated by `allow_registration` policy. Returns same shape as Login.
A unique `handle` is auto-generated from `username` (collision-safe
with `-2`, `-3` suffixes).
### Login
```
POST /auth/login
```
```json
{ "username": "jdoe", "password": "..." }
```
**Response:**
```json
{
"access_token": "eyJ...",
"refresh_token": "opaque-string",
"user": {
"id": "uuid",
"username": "jdoe",
"email": "jdoe@example.com",
"display_name": "Jane Doe",
"handle": "jdoe",
"role": "user",
"auth_source": "builtin",
"avatar_url": "/api/v1/profile/avatar?v=1234",
"created_at": "...",
"last_login": "..."
}
}
```
### Refresh
```
POST /auth/refresh
```
```json
{ "refresh_token": "opaque-string" }
```
Returns new `access_token` and `refresh_token`. Old refresh token is
invalidated (rotation).
### Logout
```
POST /auth/logout
```
```json
{ "refresh_token": "opaque-string" }
```
Revokes the refresh token. Returns `{ "message": "logged out" }`.
## mTLS Auth
Mutual TLS via reverse proxy headers. The proxy terminates TLS and
forwards cert DN fields in headers. The backend auto-provisions
users on first connection.
**Configured by:** `AUTH_MODE=mtls` + `MTLS_HEADER_*` env vars.
No explicit login/register endpoints. User identity is extracted from
the TLS certificate on every request. `password_hash` is NULL for
mTLS users.
**Headers parsed:**
- `MTLS_HEADER_CN` (default: `X-SSL-Client-CN`) — common name → username
- `MTLS_HEADER_DN` (default: `X-SSL-Client-DN`) — distinguished name → metadata
- `MTLS_HEADER_VERIFY` (default: `X-SSL-Client-Verify`) — must be `SUCCESS`
- `MTLS_HEADER_FINGERPRINT` (default: `X-SSL-Client-Fingerprint`) — stable identity
On first request with a valid cert, the system creates a user with
`auth_source=mtls` and `external_id=fingerprint`.
## OIDC Auth
OpenID Connect authorization code flow. Tested with Keycloak but
compatible with any OIDC-compliant IdP.
**Configured by:** `AUTH_MODE=oidc` + `OIDC_*` env vars.
### OIDC Login (redirect)
```
GET /auth/oidc/login
```
Redirects to the IdP authorization endpoint. Stores `state` + `nonce`
in `oidc_auth_state` table (cleaned up after callback).
### OIDC Callback
```
GET /auth/oidc/callback?code=...&state=...
```
Exchanges authorization code for tokens, validates ID token signature
via JWKS, extracts claims. Auto-provisions user on first login with
`auth_source=oidc` and `external_id=sub`. Returns HTML fragment that
passes tokens to the frontend via URL fragment.
**Claim mapping:**
- `sub``external_id`
- `preferred_username``username` + `handle`
- `email``email`
- `name``display_name`
- `groups` → synced to internal groups with `source=oidc`
**Split-horizon issuer:** `OIDC_EXTERNAL_ISSUER_URL` can differ from
`OIDC_ISSUER_URL` when the IdP's internal address (Docker/K8s service)
differs from its external address.
**OIDC env vars:**
| Var | Required | Description |
|-----|----------|-------------|
| `OIDC_ISSUER_URL` | Yes | IdP issuer URL for discovery |
| `OIDC_EXTERNAL_ISSUER_URL` | No | External issuer (if different from internal) |
| `OIDC_CLIENT_ID` | Yes | Client ID |
| `OIDC_CLIENT_SECRET` | Yes | Client secret |
| `OIDC_REDIRECT_URL` | No | Callback URL (auto-derived if not set) |
## Login Page Adaptation
The `/login` page adapts by `AUTH_MODE`:
| Mode | UI |
|------|-----|
| `builtin` | Username/password form + register link |
| `mtls` | Certificate status display |
| `oidc` | "Sign in with SSO" button |
## User Identity Fields
All auth modes produce users with:
| Field | Description |
|-------|-------------|
| `auth_source` | `builtin`, `mtls`, or `oidc` |
| `external_id` | IdP subject (OIDC) or cert fingerprint (mTLS). NULL for builtin. |
| `handle` | Unique @mention handle. Auto-generated, collision-safe. |
`handle` is the canonical identifier for @mentions (replacing username
in the resolution chain since v0.24.0).

711
docs/ICD/channels.md Normal file
View File

@@ -0,0 +1,711 @@
# Channels & Conversations
The **channel** is the universal conversation container. Messages,
tool activity, attachments, and KB bindings all hang off a channel.
Channels support multiple participants — users, personas, and
sessions — making them the foundation for collaborative and
multi-model workflows.
### Channel CRUD
**List channels** — paginated, sorted by last activity.
```
GET /channels?page=1&per_page=50
```
Returns pagination envelope. Each channel:
```json
{
"id": "uuid",
"title": "My Chat",
"type": "direct|dm|group|channel|workflow|service",
"description": "",
"model": "claude-sonnet-4-20250514",
"provider_config_id": "uuid|null",
"system_prompt": "",
"project_id": "uuid|null",
"folder": "string|null",
"tags": ["tag1", "tag2"],
"participant_count": 1,
"created_at": "...",
"updated_at": "..."
}
```
**Channel types:**
| Type | Description |
|------|-------------|
| `direct` | Single-user chat (default, backward compatible) |
| `dm` | Human-to-human direct message, AI silent unless @mentioned |
| `group` | Multi-user/multi-model collaborative channel |
| `channel` | Named persistent space, configurable ai_mode |
| `workflow` | Staged channel driven by workflow definitions |
| `service` | Autonomous task execution, no human participant |
**Create channel:**
```
POST /channels
```
```json
{
"title": "New Chat",
"type": "direct",
"description": "",
"model": "claude-sonnet-4-20250514",
"provider_config_id": "uuid",
"system_prompt": "",
"folder": null,
"tags": []
}
```
On create, the authenticated user is automatically added as a
participant with `role: "owner"`. If `model` and `provider_config_id`
are provided, the channel model roster (`channel_models`) is
auto-populated with an initial entry.
**Get channel:**
```
GET /channels/:id
```
Returns single channel object.
**Update channel:**
```
PUT /channels/:id
```
Accepts partial updates — only fields present in the body are changed.
Same field set as create.
**Delete channel:**
```
DELETE /channels/:id
```
Cascades: deletes messages, attachments, channel_models, channel_kbs.
### Message Tree
Switchboard uses a **tree** model for messages, not a flat list. Each
message has a `parent_id` (NULL for root). Editing creates a sibling
branch; regeneration creates an alternative sibling. The **cursor**
tracks which child is "active" at each branch point.
**Get active path** — the primary endpoint for loading chat history:
```
GET /channels/:id/path
```
Returns the messages along the active branch from root to leaf,
following the cursor at each fork. This is what the UI renders.
```json
{
"messages": [
{
"id": "uuid",
"channel_id": "uuid",
"parent_id": "uuid|null",
"role": "user|assistant|system|tool",
"content": "...",
"model": "claude-sonnet-4-20250514|null",
"provider_config_id": "uuid|null",
"participant_id": "uuid|null",
"participant_type": "user|persona|session|null",
"thinking": "...|null",
"tool_calls": [...],
"tool_results": [...],
"attachments": [...],
"has_siblings": true,
"sibling_index": 0,
"sibling_count": 2,
"created_at": "..."
}
]
}
```
`participant_id` and `participant_type` identify who authored the
message. For `user` messages, this is the authenticated user. For
`assistant` messages, this is the persona (if set) or null for raw
model responses. Existing messages without participants return null.
**List all messages** — includes all branches, flat:
```
GET /channels/:id/messages
```
Legacy/debug endpoint. Returns every message in the channel regardless
of branch. Not used by the standard frontend.
**Create message:**
```
POST /channels/:id/messages
```
```json
{
"role": "user",
"content": "Hello",
"parent_id": "uuid|null",
"attachments": ["attachment-uuid-1"]
}
```
**Edit message** — creates a sibling at the same level:
```
POST /channels/:id/messages/:msgId/edit
```
```json
{ "content": "Revised question" }
```
Creates a new message with the same `parent_id` as the original.
Automatically updates the cursor to point to the new sibling.
**Regenerate** — creates an alternative assistant response:
```
POST /channels/:id/messages/:msgId/regenerate
```
```json
{
"model": "claude-sonnet-4-20250514",
"provider_config_id": "uuid"
}
```
Creates a new empty assistant message as a sibling of `msgId`,
then streams the completion into it. The cursor is updated.
**Update cursor** — switch to a different branch:
```
PUT /channels/:id/cursor
```
```json
{
"message_id": "uuid",
"child_id": "uuid"
}
```
Sets which child is active at the `message_id` branch point.
Subsequent `GET /channels/:id/path` will follow the new branch.
**List siblings** — all children of a message's parent:
```
GET /channels/:id/messages/:msgId/siblings
```
Returns `{ "siblings": [...] }` with abbreviated message objects
(id, role, content preview, created_at).
### Completions & Streaming
The single most complex endpoint. Sends a message to an LLM provider
and streams the response via Server-Sent Events.
```
POST /chat/completions
```
**Request:**
```json
{
"channel_id": "uuid",
"message": "User's message text",
"model": "claude-sonnet-4-20250514",
"provider_config_id": "uuid",
"persona_id": "uuid|null",
"parent_id": "uuid|null",
"tools_enabled": true,
"tool_ids": ["web_search", "calculator"],
"attachments": ["attachment-uuid"],
"extra_body": {}
}
```
`channel_id` is required.
`extra_body` is a freeform JSON object merged into the provider request
(provider-specific parameters like `temperature`, `reasoning`, etc.).
**Participant scoping:** The requesting user must be a participant in
the channel. Messages are attributed to the authenticated user (or
session). In `group` channels, all participants see all messages in
real time via WebSocket.
**Persona resolution:** The primary completion path. If `persona_id`
is set (explicitly or via `@mention` resolution from message content),
the handler loads the persona's system prompt, model, provider config,
and KB bindings. Per-message `model`/`provider_config_id` override the
persona's defaults. In multi-persona channels, `@mention` in the
message content is parsed to resolve the target persona from the
channel's participant list.
**Routing resolution:** After config resolution, the routing policy
evaluator (`evaluateRouting()`) may redirect to a different provider
based on active policies (see §10.4). The winning provider config's
credentials are loaded and used for the actual API call.
**SSE Stream Format:**
The response is `Content-Type: text/event-stream`. Events:
```
data: {"content":"Hello"}
data: {"content":" world"}
data: {"reasoning":"Let me think..."}
event: tool_use
data: {"id":"call_1","name":"web_search","input":{"query":"..."}}
event: tool_result
data: {"id":"call_1","content":"Search results..."}
data: {"content":"Based on the search..."}
event: finish
data: {"message_id":"uuid","model":"claude-sonnet-4-20250514","usage":{"input_tokens":150,"output_tokens":42}}
data: [DONE]
```
| Event | `event:` field | Payload |
|-------|---------------|---------|
| Content delta | _(unnamed)_ | `{ "content": "text" }` |
| Reasoning delta | _(unnamed)_ | `{ "reasoning": "text" }` |
| Tool invocation | `tool_use` | `{ "id", "name", "input" }` |
| Tool result | `tool_result` | `{ "id", "content" }` |
| Stream complete | `finish` | `{ "message_id", "model", "usage" }` |
| End sentinel | _(unnamed)_ | `[DONE]` (literal string) |
| Error mid-stream | `error` | `{ "error": "message" }` |
**Tool cycle:** Tool use and tool result events can repeat up to
`max_tool_iterations` (configured server-side). The handler executes
tools, feeds results back to the model, and continues streaming.
**Response headers:**
```
X-Switchboard-Provider: providerID/configID
X-Switchboard-Route: policy-name (when routing policy is active)
```
**List available tools:**
```
GET /tools
```
Returns `{ "tools": [...] }` where each tool has `name`, `description`,
`parameters` (JSON Schema), and `category`.
### Multi-Model Roster (Raw Access)
The primary way to add AI to a channel is by adding a **persona as a
participant** (§3.7). Adding a persona automatically populates the
channel model roster with the persona's underlying model.
For the rare case where a user needs raw model access without a
persona wrapper, the `channel_models` table provides direct model
roster management. This is the 1% escape hatch — most users should
never need it.
```
GET /channels/:id/models → { "models": [...] }
POST /channels/:id/models ← { "model": "...", "provider_config_id": "...", "display_name": "..." }
PATCH /channels/:id/models/:modelId ← { "display_name": "..." }
DELETE /channels/:id/models/:modelId
```
Each roster entry:
```json
{
"id": "uuid",
"channel_id": "uuid",
"model": "claude-sonnet-4-20250514",
"provider_config_id": "uuid",
"display_name": "Claude",
"is_default": true,
"persona_id": "uuid|null",
"created_at": "..."
}
```
`persona_id`: set when this roster entry was auto-created by adding a
persona participant. Null for manually-added raw models.
**`@mention` resolution:** When a user types `@` in a channel, the
autocomplete shows persona participants first (by display name), then
raw model roster entries. The target of a `@mention` is always
resolved to a specific `provider_config_id` + `model` pair — never
an ambiguous bare model name.
### Channel Utilities
**Summarize channel** (compaction):
```
POST /channels/:id/summarize
```
Triggers conversation summarization via the utility model role.
Returns `{ "message": "summarized", "summary_id": "uuid" }`.
**Generate title:**
```
POST /channels/:id/generate-title
```
Uses the utility model to auto-generate a title from the first
exchange. Returns `{ "title": "Generated Title" }`.
### Files
All binary content associated with channels — user uploads, tool-
generated artifacts, system files — lives in the unified `files`
table backed by the ObjectStore (see §X). The old `attachments`
table and routes are removed.
**Upload (user):**
```
POST /channels/:id/files
Content-Type: multipart/form-data
```
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
**Create (tool output):**
```
POST /files
```
```json
{
"channel_id": "uuid",
"message_id": "uuid",
"filename": "generated_image.png",
"content_type": "image/png",
"display_hint": "inline",
"metadata": { "tool_name": "image_generation" }
}
```
Body: multipart `file` field or base64 `data` field.
Sets `origin: "tool_output"`. Returns file object.
**List by channel:**
```
GET /channels/:id/files?origin=user_upload
```
Returns `{ "files": [...] }`. Filterable by `origin`, `content_type`.
**List by message:**
```
GET /messages/:id/files → { "files": [...] }
```
How the frontend discovers generated artifacts to render inline.
**List by user (file manager):**
```
GET /files?page=1&per_page=50 → { "files": [...], "total": N }
```
All files owned by the authenticated user, paginated.
**Get metadata:**
```
GET /files/:id
```
```json
{
"id": "uuid",
"channel_id": "uuid",
"message_id": "uuid|null",
"user_id": "uuid",
"origin": "user_upload|tool_output|system",
"filename": "report.pdf",
"content_type": "application/pdf",
"size_bytes": 1048576,
"display_hint": "inline|download|thumbnail",
"extracted_text": "...|null",
"metadata": {},
"created_at": "...",
"updated_at": "..."
}
```
**Download:**
```
GET /files/:id/download
```
Returns the file with appropriate `Content-Type` and
`Content-Disposition` headers.
**Thumbnail:**
```
GET /files/:id/thumbnail
```
**Delete:**
```
DELETE /files/:id
```
Deletes metadata + blob. Channel deletion cascades via FK +
`DeletePrefix`.
### Channel Participants
The participant system is how users and AI interact in a channel.
**Personas are the primary way to add AI** — adding a persona as a
participant brings its full identity (name, avatar, system prompt,
model, provider config, KB bindings) into the channel. This is the
99% path. Raw model access without a persona is available via the
model roster (§3.4) for power users.
Every channel has at least one participant (the owner). `direct`
channels have exactly one user participant and optionally one or more
persona participants. `group` and `workflow` channels support multiple
participants of mixed types.
**List participants:**
```
GET /channels/:id/participants
```
Returns `{ "participants": [...] }`:
```json
{
"id": "uuid",
"channel_id": "uuid",
"participant_type": "user|persona|session",
"participant_id": "uuid",
"role": "owner|member|observer",
"display_name": "Jane Doe",
"avatar_url": "...|null",
"joined_at": "..."
}
```
**Participant types:**
| Type | Description |
|------|-------------|
| `user` | Authenticated user. `participant_id` = `users.id` |
| `persona` | AI persona added to channel. `participant_id` = `personas.id`. Brings model, system prompt, KB bindings |
| `session` | Anonymous/session participant (workflow intake). `participant_id` = session token |
**Participant roles:**
| Role | Capabilities |
|------|-------------|
| `owner` | Full control: add/remove participants, delete channel, all member capabilities |
| `member` | Send messages, trigger completions, view history |
| `observer` | Read-only: view messages, no send |
**Add participant:**
```
POST /channels/:id/participants
```
```json
{
"participant_type": "user|persona",
"participant_id": "uuid",
"role": "member"
}
```
Requires `owner` role in the channel. Adding a `persona` participant:
- Makes the persona available as an `@mention` target
- Adds the persona's model to the channel model roster automatically
- Scopes the persona's KB bindings into the channel's KB resolution chain
- The persona's avatar and display name appear in the participant list
**Update participant role:**
```
PATCH /channels/:id/participants/:participantId
```
```json
{ "role": "observer" }
```
**Remove participant:**
```
DELETE /channels/:id/participants/:participantId
```
Cannot remove the last owner. Removing a `persona` participant also
removes its auto-created model roster entry.
**Completion routing:** When `@PersonaName` appears in message
content, the completion handler resolves to that persona's model,
provider config, and system prompt. When no `@mention` is present in
a multi-persona channel, the channel's default persona (first added)
handles the response. The `persona_id` field in the completion
request (§3.3) can also be set explicitly to override `@mention`
resolution.
**Backward compatibility:** Existing `direct` channels that predate
the participant system are auto-migrated on first access — a single
`user` participant with `role: "owner"` is created from the channel's
legacy `user_id` column.
### Presence
Heartbeat-based online status. Not channel-scoped — tracks global user presence.
**Heartbeat:**
```
POST /presence/heartbeat
```
Client sends every 30 seconds. Server upserts `user_presence` row.
No request body needed. Returns `{ "ok": true }`.
**Auth:** Authenticated.
**Bulk query:**
```
GET /presence?users=alice,bob,charlie
```
Returns online status for listed usernames. Threshold: online if
last heartbeat < 90 seconds ago.
```json
{
"users": {
"alice": { "status": "online", "last_seen": "..." },
"bob": { "status": "offline", "last_seen": "..." }
}
}
```
Subsequent updates delivered via WebSocket `presence.changed` events.
**Typing indicators** are sent via WebSocket (see websocket.md). The
typing event payload includes `participant_id` and `participant_type`.
### Chat Folders
User-scoped grouping for chats. Folders have no semantic weight — they
are named drawers.
```
GET /folders → list user's folders
POST /folders ← { "name": "Research" }
PUT /folders/:id ← { "name": "Renamed" }
DELETE /folders/:id → chats become unfiled
```
**Folder object:**
```json
{
"id": "uuid",
"user_id": "uuid",
"name": "Research",
"parent_id": "uuid|null",
"sort_order": 0,
"created_at": "...",
"updated_at": "..."
}
```
Moving a chat into/out of a folder is done via `PUT /channels/:id`
with `{ "folder_id": "uuid" }` or `{ "folder_id": null }`.
**Auth:** Authenticated (user-scoped).
### Channel Configuration
Additional channel fields (set via `PUT /channels/:id`):
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `ai_mode` | string | `auto` | `auto`, `mention_only`, or `off` |
| `topic` | string | null | Short description shown in header |
| `allow_anonymous` | boolean | false | Session participants can join (workflow channels) |
| `kb_auto_inject` | boolean | false | Top-K KB chunks auto-prepend to context |
`ai_mode` behavioral matrix:
| Channel type | Default ai_mode | Notes |
|-------------|----------------|-------|
| `direct` | `auto` | Existing behavior |
| `dm` | `mention_only` | AI silent unless @mentioned |
| `group` | `auto` | Configurable per channel |
| `channel` | `auto` | Configurable per channel |
### Mark Read
```
POST /channels/:id/mark-read
```
Updates the user's `last_read_at` cursor. Used for unread count
calculation.
**Auth:** Authenticated.
### User Search
```
GET /users/search?q=alice
```
Returns `{ "data": [{ "id", "username", "handle", "display_name", "avatar_url" }] }`.
Used for DM creation and participant picker. Matches on handle.
**Auth:** Authenticated.
---

250
docs/ICD/enums.md Normal file
View File

@@ -0,0 +1,250 @@
# Appendix: Enums & Constants
All enum values used across the API. Definitive source of truth.
## Channel Types
| Value | Description |
|-------|-------------|
| `direct` | Single-user chat (default, backward compatible) |
| `dm` | Human-to-human direct message, AI silent unless @mentioned |
| `group` | Multi-user/multi-model collaborative channel |
| `channel` | Named persistent space, configurable ai_mode |
| `workflow` | Staged channel driven by workflow definitions |
| `service` | Autonomous task execution, no human participant |
## Channel AI Modes
| Value | Description |
|-------|-------------|
| `auto` | AI responds to every message (default) |
| `mention_only` | AI responds only when @mentioned (default for `dm`) |
| `off` | AI disabled entirely |
## Participant Types
| Value | Description |
|-------|-------------|
| `user` | Authenticated user. `participant_id` = `users.id` |
| `persona` | AI persona. `participant_id` = `personas.id` |
| `session` | Anonymous visitor. `participant_id` = session token |
## Participant Roles
| Value | Description |
|-------|-------------|
| `owner` | Full control: add/remove participants, delete channel |
| `member` | Send messages, trigger completions, view history |
| `observer` | Read-only: view messages, no send |
| `visitor` | Anonymous session participant (workflow intake) |
## Presence Statuses
`online`, `away`, `offline`
## Message Roles
`user`, `assistant`, `system`, `tool`
## Scopes
`personal`, `team`, `global`
## Visibility (Model Catalog)
`enabled`, `disabled`, `team`
## User Roles
`admin`, `user`
## Team Member Roles
`admin`, `member`
## Provider Types
`openai`, `anthropic`, `openrouter`, `venice` (extensible via registry)
## Model Types
`chat`, `embedding`, `image`
## Memory Scopes
`user`, `persona`, `persona_user`
## Memory Statuses
`active`, `pending_review`, `archived`
## Workspace Owner Types
`user`, `project`, `channel`, `team`
## Workspace Statuses
`active`, `archived`, `deleting`
## File Index Statuses
`pending`, `indexing`, `ready`, `error`, `skipped`
## KB Document Statuses
`pending`, `chunking`, `embedding`, `ready`, `error`
## Provider Health Statuses
`healthy`, `degraded`, `down`
## Routing Policy Types
| Value | Description |
|-------|-------------|
| `provider_prefer` | Ordered provider fallback list |
| `team_route` | Restrict team to specific providers |
| `cost_limit` | Heuristic cost cap per request |
| `model_alias` | Alias → provider+model rewrite |
| `capability_match` | Match cheapest model with required capabilities |
## Extension Tiers
`browser` (implemented), `starlark` (future), `sidecar` (future)
## Grant Types
| Value | Visibility |
|-------|-----------|
| `team_only` | Only the owning team |
| `global` | All authenticated users |
| `groups` | Members of specified groups |
## Resource Grant Resource Types
`persona`, `knowledge_base`, `project`
## Group Sources
| Value | Description |
|-------|-------------|
| `manual` | Admin-created |
| `oidc` | Synced from IdP groups claim |
| `system` | Platform-seeded (e.g., Everyone group) |
## Auth Sources
| Value | Description |
|-------|-------------|
| `builtin` | Username/password, bcrypt |
| `mtls` | Mutual TLS via reverse proxy |
| `oidc` | OpenID Connect (Keycloak etc.) |
## Workflow Statuses
| Value | Description |
|-------|-------------|
| `active` | Instance is running |
| `completed` | All stages finished |
| `stale` | No activity within threshold |
| `cancelled` | Manually cancelled |
## Workflow Entry Modes
`public_link`, `team_only`
## Workflow History Modes
| Value | Description |
|-------|-------------|
| `full` | Next stage sees complete chat history |
| `summary` | Utility-role summary of previous stages |
| `fresh` | Clean slate — no history carried forward |
## Task Types
`prompt`, `workflow`
## Task Run Statuses
| Value | Description |
|-------|-------------|
| `running` | Currently executing |
| `completed` | Finished successfully |
| `failed` | Error during execution |
| `budget_exceeded` | Hit token/tool/wall-clock limit |
| `cancelled` | Manually killed |
## Task Scopes
`personal`, `team`, `global`
## Task Output Modes
`channel`, `note`, `webhook`
## Assignment Statuses
`unassigned`, `claimed`, `completed`
## Surface Sources
`core`, `extension`
## Notification Types
`role.fallback`, `memory.extracted`, `system.announcement`,
`workflow.assigned`, `workflow.claimed`, `task.completed`,
`task.failed`, `user.mentioned`
## Git Auth Types
`https_pat`, `https_basic`, `ssh_key`
## File Origins
`user_upload`, `tool_output`, `system`
## File Display Hints
`inline`, `download`, `thumbnail`
## Proxy Modes
`system`, `direct`, `custom`
## Permissions
12 permission constants, `domain.action` convention:
| Permission | Description |
|-----------|-------------|
| `model.use` | Use AI models for completions |
| `kb.read` | Search knowledge bases |
| `kb.create` | Create knowledge bases |
| `kb.write` | Upload documents to KBs |
| `channel.create` | Create new channels |
| `channel.invite` | Add participants to channels |
| `persona.create` | Create personal personas |
| `persona.manage` | Edit/delete own personas |
| `workflow.create` | Create/edit workflow definitions |
| `tasks.create` | Create/manage tasks |
| `tasks.admin` | Manage all tasks (admin) |
| `admin.access` | Access admin panel |
Permissions are granted via groups. The `Everyone` group
(ID `00000000-0000-0000-0000-000000000001`) provides default
permissions to all authenticated users. It is system-seeded
and editable by admins.
## Policies
| Key | Default | Description |
|-----|---------|-------------|
| `allow_registration` | `"true"` | Allow new user self-registration |
| `allow_user_byok` | `"false"` | Allow users to add personal API keys |
| `allow_user_personas` | `"false"` | Allow users to create personal Personas |
| `allow_team_providers` | `"true"` | Allow team admins to configure team providers |
| `allow_raw_model_access` | `"true"` | Allow raw model selection without persona |
| `kb_direct_access` | `"true"` | Show KB picker in channel (false = strict enterprise) |
| `default_user_active` | `"false"` | New users active immediately (vs admin approval) |

34
docs/ICD/extensions.md Normal file
View File

@@ -0,0 +1,34 @@
# Extensions
Plugin system with three tiers: Browser JS (client-side), Starlark
sandbox (server-side, future), Sidecar containers (server-side, future).
Currently only Browser tier is implemented.
### User Extensions
```
GET /extensions → { "extensions": [...] }
POST /extensions/:id/settings ← { "enabled": true, "config": {...} }
GET /extensions/:id/manifest → full manifest.json
GET /extensions/tools → { "tools": [tool schema objects] }
```
### Admin Extension Management
```
GET /admin/extensions → { "extensions": [...] }
POST /admin/extensions ← { manifest + script content }
PUT /admin/extensions/:id ← updated manifest/script
DELETE /admin/extensions/:id
```
### Asset Serving
```
GET /extensions/:id/assets/*path
```
Public (no auth required). Serves static assets (icons, CSS, JS)
from extension bundles.
---

167
docs/ICD/knowledge.md Normal file
View File

@@ -0,0 +1,167 @@
# Knowledge Bases
The **Knowledge Base** (KB) is the RAG system: upload documents → chunk
→ embed (pgvector / app-level cosine for SQLite) → search via
`kb_search` tool during completions.
### KB CRUD
```
GET /knowledge-bases → { "data": [KB objects] }
POST /knowledge-bases ← { "name", "description", "scope", "team_id" }
GET /knowledge-bases/:id → KB object
PUT /knowledge-bases/:id ← partial update
DELETE /knowledge-bases/:id
```
KB object:
```json
{
"id": "uuid",
"name": "Product Docs",
"description": "Internal product documentation",
"scope": "personal|team|global",
"owner_id": "uuid|null",
"team_id": "uuid|null",
"discoverable": true,
"embedding_model": "text-embedding-3-small",
"chunk_size": 512,
"chunk_overlap": 50,
"document_count": 12,
"created_at": "...",
"updated_at": "..."
}
```
### Documents
**Upload:**
```
POST /knowledge-bases/:id/documents
Content-Type: multipart/form-data
```
Field: `file`. Supported: PDF, DOCX, TXT, MD, HTML, CSV, XLSX, PPTX.
Returns the document object with `status: "pending"`.
**List:**
```
GET /knowledge-bases/:id/documents
```
Returns `{ "data": [document objects] }`.
**Status** (poll during processing):
```
GET /knowledge-bases/:id/documents/:docId/status
```
Status progression: `pending → chunking → embedding → ready | error`.
**Delete:**
```
DELETE /knowledge-bases/:id/documents/:docId
```
### Search
```
POST /knowledge-bases/:id/search
```
```json
{
"query": "How do I configure SSO?",
"limit": 5
}
```
Returns `{ "results": [{ "content", "score", "document_id", "metadata" }] }`.
### Rebuild
Re-chunks and re-embeds all documents:
```
POST /knowledge-bases/:id/rebuild
```
### Channel KB Bindings
A channel can have KBs explicitly bound to it (in addition to any KBs
coming from the active Persona or parent Project).
**Get:**
```
GET /channels/:id/knowledge-bases
```
Returns `{ "data": [ChannelKB objects] }`:
```json
{
"kb_id": "uuid",
"kb_name": "Product Docs",
"enabled": true,
"document_count": 12
}
```
**Set** (replace all):
```
PUT /channels/:id/knowledge-bases
```
```json
{ "kb_ids": ["kb-uuid-1", "kb-uuid-2"] }
```
### Discoverable KBs
The `discoverable` flag controls whether a KB appears in the user's KB
picker. Non-discoverable KBs are hidden from direct access but still
searchable through Persona bindings (enterprise KB mode).
**List discoverable** (for KB picker UI):
```
GET /knowledge-bases-discoverable
```
Returns `{ "data": [KB objects] }` — only KBs the user can see
(personal + team + discoverable global).
**Toggle discoverability** (admin-enforced in handler):
```
PUT /knowledge-bases/:id/discoverable
```
```json
{ "discoverable": false }
```
### KB Resolution Chain
When a completion fires, KBs are resolved in priority order:
```
Persona KBs → Project KBs → Channel KBs → Personal KBs
```
The `BuildKBHint` function assembles all applicable KBs into the
system prompt, and the `kb_search` tool searches across all of them.
### Persona KB Bindings
See §4.4. Persona-KB binding is the primary enterprise mechanism for
controlled KB access.
---

48
docs/ICD/memory.md Normal file
View File

@@ -0,0 +1,48 @@
# Memory
**Long-term memory** extracted from conversations. The system
identifies facts, preferences, and context about the user and stores
them for injection into future conversations.
### User Memory
```
GET /memories → { "data": [memory objects] }
GET /memories/count → { "count": 42 }
PUT /memories/:id ← { "content": "updated text" }
DELETE /memories/:id
POST /memories/:id/approve → approve a pending memory
POST /memories/:id/reject → reject a pending memory
```
Memory object:
```json
{
"id": "uuid",
"user_id": "uuid",
"persona_id": "uuid|null",
"scope": "user|persona",
"content": "User prefers Go for backend work",
"source_channel_id": "uuid",
"status": "pending|approved|rejected",
"confidence": 0.85,
"created_at": "...",
"updated_at": "..."
}
```
`scope`: `user` memories apply across all conversations. `persona`
memories are specific to interactions with that Persona.
### Admin Memory Review
```
GET /admin/memories/pending → { "data": [memory objects with user info] }
POST /admin/memories/bulk-approve ← { "ids": ["uuid1", "uuid2"] }
```
Platform admin can review and bulk-approve pending memories across
all users.
---

88
docs/ICD/models.md Normal file
View File

@@ -0,0 +1,88 @@
# Models & Preferences
What models are available to the current user and how they control
visibility.
### Enabled Models
The primary endpoint for populating model selectors:
```
GET /models/enabled
```
Returns `{ "models": [UserModel objects] }`:
```json
{
"id": "composite-id",
"model_id": "claude-sonnet-4-20250514",
"provider_config_id": "uuid",
"provider_config_name": "Anthropic",
"provider": "anthropic",
"display_name": "Claude Sonnet 4",
"model_type": "chat|embedding|image",
"context_window": 200000,
"max_output_tokens": 8192,
"supports_vision": true,
"supports_tools": true,
"supports_thinking": true,
"supports_streaming": true,
"input_price_per_m": 3.00,
"output_price_per_m": 15.00,
"provider_status": "healthy|degraded|down|null",
"scope": "global|team|personal",
"source": "catalog|heuristic",
"is_persona": false,
"persona_id": "uuid|null",
"persona_scope": "global|team|personal|null",
"persona_avatar": "url|null",
"persona_team_name": "string|null"
}
```
The capabilities on each model are resolved through the three-tier
chain: catalog DB → heuristic inference → admin overrides (see §10.5).
When `is_persona` is `true`, the capability fields (`context_window`,
`max_output_tokens`, `supports_*`, pricing, `provider_status`) are
inherited from the persona's underlying model. The frontend renders
the same capability pills and badges for a persona as for its raw
model — the persona adds identity, not capability restrictions.
### User Model Preferences
Users can hide models they don't want to see and set per-model
defaults. Preferences are keyed on the **composite identity**
`provider_config_id:model_id` — the same model from different
providers can have independent visibility.
```
GET /models/preferences → { "preferences": [PreferenceEntry objects] }
PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", "hidden": true }
POST /models/preferences/bulk ← { "entries": [{ "model_id": "...", "provider_config_id": "uuid", "hidden": true }] }
```
PreferenceEntry:
```json
{
"model_id": "grok-4.1-fast",
"provider_config_id": "uuid",
"hidden": true,
"preferred_temperature": 0.7,
"preferred_max_tokens": 4096,
"sort_order": 0
}
```
**Identity rule:** `provider_config_id` is required on write. The same
bare `model_id` from two different provider configs (e.g. global Venice
vs personal BYOK Venice) are independent preference entries. The
frontend composite ID format is `{provider_config_id}:{model_id}`.
**Persona preferences:** Personas are not in this table. A persona's
visibility is controlled by the grant system (§15.3) and the
`is_active` flag, not by model preferences.
---

105
docs/ICD/notes.md Normal file
View File

@@ -0,0 +1,105 @@
# Notes
**Obsidian-style** knowledge graph with `[[wikilinks]]`, backlinks,
graph visualization, and folder organization. Notes are user-scoped
(personal notebook). Future: channel-scoped notes for workflow
artifacts.
### CRUD
```
GET /notes → paginated list of noteListItem
POST /notes ← { "title", "content", "folder" }
GET /notes/:id → full note object
PUT /notes/:id ← { "title", "content", "folder" }
DELETE /notes/:id
```
Note object:
```json
{
"id": "uuid",
"user_id": "uuid",
"title": "Meeting Notes",
"content": "# Meeting Notes\n\nDiscussed [[Project Alpha]]...",
"folder": "work",
"source_message_id": "uuid|null",
"created_at": "...",
"updated_at": "..."
}
```
`source_message_id` links a note back to the chat message that
created it (provenance for AI-generated notes).
`noteListItem` is the same but with `content` truncated or omitted.
### Search
**Full-text search:**
```
GET /notes/search?q=project+alpha
```
Uses `plainto_tsquery` (Postgres) or `LIKE` fallback (SQLite).
Returns `{ "data": [searchResult objects] }` with match highlights.
**Title search** (lightweight, for wikilink autocomplete):
```
GET /notes/search-titles?q=proj
```
Returns `{ "data": [{ "id", "title" }] }`.
### Graph
**Full graph topology:**
```
GET /notes/graph
```
```json
{
"nodes": [{ "id": "uuid", "title": "Note Title" }],
"edges": [{ "source": "uuid", "target": "uuid" }],
"unresolved": [{ "source": "uuid", "target_title": "Missing Note" }]
}
```
Edges are directed: source contains `[[target_title]]`. Unresolved
edges have a `target_title` but no matching note (dangling wikilink).
When a note with that title is created, the edge auto-resolves.
**Backlinks** (who links to this note):
```
GET /notes/:id/backlinks
```
Returns `{ "data": [note objects] }` — all notes containing
`[[this note's title]]`.
### Folders
```
GET /notes/folders
```
Returns `{ "folders": ["work", "personal", "archive"] }` — distinct
folder values across all user notes.
### Bulk Operations
```
POST /notes/bulk-delete
```
```json
{ "ids": ["uuid-1", "uuid-2"] }
```
---

44
docs/ICD/notifications.md Normal file
View File

@@ -0,0 +1,44 @@
# Notifications
Real-time notification infrastructure with WebSocket delivery and
optional email transport.
### CRUD
```
GET /notifications → paginated list
GET /notifications/unread-count → { "count": 5 }
PATCH /notifications/:id/read → mark one as read
POST /notifications/mark-all-read → mark all as read
DELETE /notifications/:id
```
Notification object:
```json
{
"id": "uuid",
"user_id": "uuid",
"type": "role.fallback|memory.extracted|system.announcement|...",
"title": "Role Fallback Triggered",
"body": "Primary model unavailable, using fallback",
"metadata": {},
"read": false,
"created_at": "..."
}
```
### Preferences
Users control per-type delivery:
```
GET /notifications/preferences → { "preferences": [...] }
PUT /notifications/preferences/:type ← { "in_app": true, "email": false }
DELETE /notifications/preferences/:type → reset to default
```
Resolution: specific type pref → user wildcard `*` pref → system
default (`in_app=true`, `email=false`).
---

204
docs/ICD/personas.md Normal file
View File

@@ -0,0 +1,204 @@
# Personas
A **Persona** is the unit of access control and AI identity: system
prompt + model + provider config + KB bindings + grant scoping. Users
interact with Personas, not raw provider configs. Admins curate which
Personas are available; team admins create team-scoped Personas.
**Model inheritance:** A Persona inherits all properties of its
underlying model. This includes context window size, max output
tokens, supported capabilities (thinking, tools, vision, streaming),
input/output pricing, and provider status. When the frontend displays
a Persona, it resolves the underlying model's capabilities and shows
the same pills/badges (e.g. thinking, tools, 200k context) that the
raw model would show. The Persona adds identity (name, avatar, system
prompt, KB bindings) on top of the model's capabilities — it never
masks or reduces them.
Three scopes, three route namespaces, one domain:
| Scope | Routes | Who manages |
|-------|--------|-------------|
| Personal | `GET/POST/PUT/DELETE /personas` | The user |
| Team | `GET/POST/PUT/DELETE /teams/:teamId/personas` | Team admin |
| Global (admin) | `GET/POST/PUT/DELETE /admin/personas` | Platform admin |
All three return the same Persona object shape:
```json
{
"id": "uuid",
"name": "Code Reviewer",
"description": "Reviews code for bugs and style",
"system_prompt": "You are a senior code reviewer...",
"model": "claude-sonnet-4-20250514",
"provider_config_id": "uuid|null",
"scope": "personal|team|global",
"owner_id": "uuid|null",
"is_default": false,
"is_active": true,
"avatar_url": "/api/v1/personas/uuid/avatar?v=123",
"inherited": {
"context_window": 200000,
"max_output_tokens": 8192,
"supports_vision": true,
"supports_tools": true,
"supports_thinking": true,
"supports_streaming": true,
"input_price_per_m": 3.00,
"output_price_per_m": 15.00,
"provider_status": "healthy|degraded|down|null"
},
"created_at": "...",
"updated_at": "..."
}
```
The `inherited` block is populated by the backend from the resolved
model capabilities at response time — it is not stored on the persona.
If the underlying model's capabilities change (e.g. provider upgrades
context window), the persona inherits the new values automatically.
The frontend uses `inherited` to render capability pills and context
size badges identical to the raw model.
### Personal Personas
Gated by `allow_user_personas` policy.
```
GET /personas → { "personas": [...] }
POST /personas ← { "name", "description", "system_prompt", "model", ... }
PUT /personas/:id ← partial update
DELETE /personas/:id
```
### Team Personas
```
GET /teams/:teamId/personas → { "personas": [...] }
POST /teams/:teamId/personas ← same shape
PUT /teams/:teamId/personas/:id
DELETE /teams/:teamId/personas/:id
```
### Admin (Global) Personas
```
GET /admin/personas → { "personas": [...] }
POST /admin/personas ← same shape
PUT /admin/personas/:id
DELETE /admin/personas/:id
```
### Persona KB Bindings
A Persona can have Knowledge Bases bound to it. When a channel uses
that Persona, the bound KBs are automatically scoped into `kb_search`
tool calls. Users never see or manage the underlying KBs directly —
they talk to the Persona.
**Get bindings:**
```
GET /personas/:id/knowledge-bases → { "data": [KB objects] }
GET /teams/:teamId/personas/:id/knowledge-bases → { "data": [...] }
GET /admin/personas/:id/knowledge-bases → { "data": [...] }
```
**Set bindings** (replace all):
```
PUT /personas/:id/knowledge-bases
PUT /teams/:teamId/personas/:id/knowledge-bases
PUT /admin/personas/:id/knowledge-bases
```
```json
{
"kb_ids": ["kb-uuid-1", "kb-uuid-2"],
"auto_search": {
"kb-uuid-1": true,
"kb-uuid-2": false
}
}
```
`auto_search`: when `true`, the KB is always searched (prepended to
context). When `false`, it's available via the `kb_search` tool but
not auto-injected.
### Persona Avatars
```
POST /personas/:id/avatar ← multipart/form-data (field: "avatar")
DELETE /personas/:id/avatar
POST /admin/personas/:id/avatar
DELETE /admin/personas/:id/avatar
```
### Persona Tool Grants
Control which tools a persona can use during completions. The completion
handler applies tool grants as a second-pass allowlist.
```
GET /personas/:id/tool-grants → { "grants": ["web_search", "kb_search", ...] }
PUT /personas/:id/tool-grants ← { "tool_ids": ["web_search", "calculator"] }
```
Admin equivalents:
```
GET /admin/personas/:id/tool-grants
PUT /admin/personas/:id/tool-grants
```
When a workflow version is published, persona tool grants at that moment
are frozen into the version snapshot.
### Persona Groups
Saved roster templates. A persona group is a named set of personas that
can be stamped onto new conversations.
```
GET /persona-groups → { "data": [...] }
POST /persona-groups ← { "name", "description", "scope", "team_id" }
GET /persona-groups/:id → group with members
PUT /persona-groups/:id ← partial update
DELETE /persona-groups/:id
```
**Members:**
```
POST /persona-groups/:id/members ← { "persona_id", "is_leader": false, "sort_order": 0 }
DELETE /persona-groups/:id/members/:memberId
```
`is_leader`: the leader persona responds when no @mention is present in
a group channel. Only one leader per group.
**Persona Group Object:**
```json
{
"id": "uuid",
"name": "Support Team",
"description": "...",
"owner_id": "uuid",
"scope": "personal|team|global",
"team_id": "uuid|null",
"members": [
{ "id": "uuid", "persona_id": "uuid", "is_leader": true, "sort_order": 0 }
],
"created_at": "...",
"updated_at": "..."
}
```
### Resource Grants
See [teams.md](teams.md) §15.3 for the grant system. Personas use grants
to control who can see and use them beyond their base scope.

55
docs/ICD/profile.md Normal file
View File

@@ -0,0 +1,55 @@
# User Profile & Settings
### Profile
```
GET /profile → profileResponse
PUT /profile ← { "display_name", "email" }
```
```json
{
"id": "uuid",
"username": "jdoe",
"email": "jdoe@example.com",
"display_name": "Jane Doe",
"role": "user|admin",
"avatar_url": "...",
"created_at": "...",
"last_login": "..."
}
```
### Avatar
```
POST /profile/avatar ← multipart/form-data (field: "avatar")
DELETE /profile/avatar
```
### Password
```
POST /profile/password
```
```json
{
"current_password": "...",
"new_password": "..."
}
```
On password change, the UEK is re-wrapped with the new password
(BYOK keys remain accessible without re-encryption).
### App Settings
```
GET /settings → { "settings": {...} }
PUT /settings ← { "theme", "editor_keybindings", ... }
```
User-level preferences (theme, keybindings, default model, etc.).
---

87
docs/ICD/projects.md Normal file
View File

@@ -0,0 +1,87 @@
# Projects
**Projects** are organizational containers that group channels,
knowledge bases, notes, and files. They follow the scope model
(personal, team, global).
### Project CRUD
```
GET /projects → { "data": [...] }
POST /projects ← { "name", "description", "scope", "team_id", "persona_id", "system_prompt" }
GET /projects/:id → project object
PUT /projects/:id ← partial update
DELETE /projects/:id
```
Project object:
```json
{
"id": "uuid",
"name": "Q3 Research",
"description": "...",
"scope": "personal|team|global",
"owner_id": "uuid",
"team_id": "uuid|null",
"persona_id": "uuid|null",
"system_prompt": "...|null",
"is_archived": false,
"channel_count": 5,
"created_at": "...",
"updated_at": "..."
}
```
### Channel Association
```
GET /projects/:id/channels → { "data": [channel objects] }
POST /projects/:id/channels ← { "channel_id": "uuid" }
DELETE /projects/:id/channels/:channelId
PUT /projects/:id/channels/reorder ← { "channel_ids": ["uuid1", "uuid2"] }
```
A channel can only belong to one project. `POST` performs an atomic
move if the channel is already in a different project.
### KB Association
```
GET /projects/:id/knowledge-bases → { "data": [KB objects] }
POST /projects/:id/knowledge-bases ← { "kb_id": "uuid" }
DELETE /projects/:id/knowledge-bases/:kbId
```
KBs bound to a project are automatically available to every channel
in that project (see §5.7 resolution chain).
### Note Association
```
GET /projects/:id/notes → { "data": [note objects] }
POST /projects/:id/notes ← { "note_id": "uuid" }
DELETE /projects/:id/notes/:noteId
```
### Project Files
```
GET /projects/:id/files → { "files": [...] }
POST /projects/:id/files ← multipart/form-data
```
Project-level file uploads (distinct from workspace files and channel
attachments).
### Admin Project Management
```
GET /admin/projects → { "data": [...] }
DELETE /admin/projects/:id
```
Cross-instance visibility for platform admins. BYOK personal projects
remain private (admin can delete but not read content).
---

221
docs/ICD/providers.md Normal file
View File

@@ -0,0 +1,221 @@
# Providers & Routing
The **multi-provider** system. One or more LLM providers are configured,
each with their own API keys, endpoints, and model catalogs. The routing
layer decides which provider handles each request.
### User BYOK Provider Configs
Gated by `allow_user_byok` policy. Personal API keys are encrypted with
the user's UEK (per-user encryption key, Argon2id-derived). Platform
admins cannot recover personal keys.
```
GET /api-configs → { "configs": [safeConfig objects] }
POST /api-configs ← { "name", "provider", "endpoint", "api_key", ... }
GET /api-configs/:id → safeConfig
PUT /api-configs/:id ← partial update (api_key optional)
DELETE /api-configs/:id
```
`safeConfig` — API keys are **never** returned:
```json
{
"id": "uuid",
"name": "My OpenAI",
"provider": "openai",
"endpoint": "https://api.openai.com/v1",
"model_default": "gpt-4o",
"scope": "personal",
"owner_id": "uuid",
"is_active": true,
"has_key": true,
"config": {},
"headers": {},
"settings": {},
"created_at": "..."
}
```
**List models for a user config:**
```
GET /api-configs/:id/models
```
Returns `{ "models": [catalog entries] }`.
**Fetch/sync models from provider API:**
```
POST /api-configs/:id/models/fetch
```
Calls the provider's model list API, upserts into the local catalog.
### Admin Global Provider Configs
```
GET /admin/configs → { "configs": [configWithKey objects] }
POST /admin/configs ← { "name", "provider", "endpoint", "api_key", "config", "headers", "settings", "is_private" }
PUT /admin/configs/:id ← partial update
DELETE /admin/configs/:id
```
`configWithKey` is the same as `safeConfig` but comes from
`ListGlobal` — still redacts API keys, just adds the `has_key` flag.
Admin configs use the `ENCRYPTION_KEY` env var (not per-user UEK).
`is_private`: when true, the config is available for admin-created
Personas but not directly selectable by users.
### Model Catalog (Admin)
The catalog is populated by fetching from provider APIs and stores
model metadata (capabilities, context window, pricing).
```
GET /admin/models → { "models": [catalog entries] }
PUT /admin/models/:id ← { "visibility", "display_name", ... }
PUT /admin/models/bulk ← { "provider_config_id", "visibility" }
DELETE /admin/models/:id
POST /admin/models/fetch ← { "provider_config_id": "uuid|empty" }
```
**Fetch** with empty `provider_config_id` syncs ALL active global
providers. Returns:
```json
{
"message": "models synced",
"added": 5,
"updated": 12,
"total": 47
}
```
Or for multi-provider fetch: `{ "added", "updated", "total", "errors": [...] }`.
**Bulk visibility** sets all models for a provider (or all models
globally if no `provider_config_id`) to the specified visibility
(`enabled`, `disabled`, `team`).
### Provider Health
Real-time health tracking per provider config. The health accumulator
records success/failure/latency on every completion, flushes to DB
every 60 seconds.
**Get all:**
```
GET /admin/providers/health
```
Returns `{ "data": [ProviderHealth objects] }`:
```json
{
"provider_config_id": "uuid",
"provider_config_name": "OpenAI Production",
"status": "healthy|degraded|down",
"error_rate": 0.02,
"avg_latency_ms": 450,
"timeout_rate": 0.01,
"rate_limit_count": 3,
"last_check": "...",
"last_error": "...|null"
}
```
**Get single:**
```
GET /admin/providers/:id/health
```
**Auto-disable:** After `PROVIDER_AUTO_DISABLE_THRESHOLD` consecutive
"down" windows (default: 3), the provider is automatically deactivated.
### Capability Overrides
Admin can override any model capability detected by the catalog or
heuristic layer.
```
GET /admin/capability-overrides → { "data": [...] }
GET /admin/models/:id/capabilities → capabilities for one model
PUT /admin/models/:id/capabilities ← { "field": "value" }
DELETE /admin/models/:id/capabilities/:overrideId
```
Override fields: `supports_vision`, `supports_tools`, `supports_thinking`,
`context_window`, `max_output_tokens`, etc.
### Routing Policies
Policy-based request routing. Evaluated after model/config resolution,
before provider dispatch.
**Admin CRUD:**
```
GET /admin/routing/policies → { "data": [...] }
GET /admin/routing/policies/:id → policy object
POST /admin/routing/policies ← { "name", "scope", "team_id", "priority", "policy_type", "config", "is_active" }
PUT /admin/routing/policies/:id
DELETE /admin/routing/policies/:id
```
Policy object:
```json
{
"id": "uuid",
"name": "Prefer Anthropic",
"scope": "global|team",
"team_id": "uuid|null",
"priority": 10,
"policy_type": "provider_prefer|team_route|cost_limit|model_alias|capability_match",
"config": {},
"is_active": true
}
```
| Policy type | Config | Behavior |
|------------|--------|----------|
| `provider_prefer` | `{ "providers": ["cfg-1", "cfg-2"] }` | Ordered fallback list |
| `team_route` | `{ "providers": ["cfg-1"] }` | Restrict team to specific providers |
| `cost_limit` | `{ "max_cost_per_request": 0.50 }` | Heuristic cost cap |
| `model_alias` | `{ "alias": "fast", "target_model": "...", "target_config": "..." }` | Alias → provider+model rewrite |
| `capability_match` | `{ "require": ["tool_calling"], "prefer": "cheapest" }` | Match cheapest model with required capabilities |
**Dry-run test:**
```
POST /admin/routing/test
```
```json
{
"model": "claude-sonnet-4-20250514",
"user_id": "uuid",
"team_id": "uuid|null"
}
```
Returns the ranked candidate list with health status for each.
### Provider Types
Registry of supported provider types with metadata.
```
GET /admin/provider-types
```
Returns `{ "types": [...] }` with name, display name, default endpoint,
profile schema, and supported features per type.
---

118
docs/ICD/surfaces.md Normal file
View File

@@ -0,0 +1,118 @@
# Surfaces
Dynamic surface lifecycle. Core surfaces are registered at startup.
Extension surfaces are uploaded by admins and served from the
`surface_registry` table.
## User Endpoints
### List Enabled Surfaces
```
GET /surfaces
```
Returns surfaces the user can access (enabled in registry).
```json
{
"surfaces": [
{ "id": "chat", "title": "Chat", "source": "core", "enabled": true },
{ "id": "editor", "title": "Editor", "source": "core", "enabled": true },
{ "id": "hello-dashboard", "title": "Hello Dashboard", "source": "extension", "enabled": true }
]
}
```
**Auth:** Authenticated.
## Admin Endpoints
### List All Surfaces
```
GET /admin/surfaces
```
Returns all surfaces including disabled ones.
**Auth:** Platform admin.
### Get Surface
```
GET /admin/surfaces/:id
```
Returns full surface details including manifest.
### Install Surface
```
POST /admin/surfaces/install
Content-Type: multipart/form-data
```
Field: `archive` — a `.surface` archive (tar.gz containing `manifest.json`
+ static assets). The manifest declares `id`, `title`, `route`, required
data loaders, scripts, and CSS.
**Auth:** Platform admin.
### Enable / Disable
```
PUT /admin/surfaces/:id/enable
PUT /admin/surfaces/:id/disable
```
Disabled surfaces redirect to `/` and hide from navigation.
**Auth:** Platform admin.
### Delete Surface
```
DELETE /admin/surfaces/:id
```
Removes the surface and its static assets. Core surfaces cannot be deleted.
**Auth:** Platform admin.
## Surface Registry Object
```json
{
"id": "hello-dashboard",
"title": "Hello Dashboard",
"manifest": { "route": "/s/hello-dashboard", "scripts": [...], "css": [...] },
"enabled": true,
"source": "core|extension",
"installed_at": "...",
"updated_at": "..."
}
```
## Page Routes
Extension surfaces are served at `/s/:slug` via `RenderExtensionSurface()`.
The page engine does a runtime DB lookup — no server restart needed after install.
Static assets are served from `/surfaces/:id/` (nginx location block in
both unified and split deployment).
## Surface Archive Format
A `.surface` file is a tar.gz containing:
```
manifest.json — required
script.js — optional
style.css — optional
assets/ — optional (icons, images, etc.)
```
See [EXTENSION-SURFACES.md](../EXTENSION-SURFACES.md) for the full
authoring guide including manifest schema, platform API, and CSS
custom properties.

290
docs/ICD/tasks.md Normal file
View File

@@ -0,0 +1,290 @@
# Tasks
Autonomous agent scheduling. A task is a cron-driven or one-shot prompt
execution in a headless service channel — no human participant in the loop.
## Configuration
Global config keys (set via `PUT /admin/settings/:key`):
| Key | Default | Description |
|-----|---------|-------------|
| `tasks.enabled` | `true` | Master kill switch for task scheduler |
| `tasks.allow_personal` | `true` | Allow non-admin users to create tasks |
| `tasks.max_concurrent` | `5` | Max tasks running simultaneously |
| `tasks.personal_require_byok` | `false` | Personal tasks must use BYOK provider |
Default budget ceilings (overridable per task):
| Key | Default |
|-----|---------|
| `tasks.default_max_tokens` | `4096` |
| `tasks.default_max_tool_calls` | `10` |
| `tasks.default_max_wall_clock` | `300` (seconds) |
## Personal Tasks
### List My Tasks
```
GET /tasks
```
Returns tasks owned by the current user.
**Auth:** Authenticated.
### Create Task
```
POST /tasks
```
```json
{
"name": "Morning News Digest",
"description": "Summarize top tech news",
"task_type": "prompt",
"persona_id": "uuid|null",
"model_id": "claude-sonnet-4-20250514",
"system_prompt": "You are a news summarizer.",
"user_prompt": "Summarize the top 5 tech news stories from today.",
"schedule": "@daily",
"timezone": "America/New_York",
"max_tokens": 4096,
"max_tool_calls": 10,
"max_wall_clock": 300,
"output_mode": "channel",
"webhook_url": "https://...",
"provider_config_id": "uuid|null",
"notify_on_complete": false,
"notify_on_failure": true,
"tool_grants": ["web_search", "url_fetch"]
}
```
`task_type`: `prompt` (direct LLM execution) or `workflow` (instantiate
a workflow in the service channel).
`schedule`: cron expression or `once`. Supported patterns: `@hourly`,
`@daily`, `@weekly`, `*/N * * * *` (every N minutes), full 5-field cron
via `robfig/cron/v3`.
`output_mode`: `channel` (default — output stays in service channel),
`note` (creates a note from the output), `webhook` (POST result to URL).
**Auth:** `tasks.create` permission required.
### Get Task
```
GET /tasks/:id
```
**Auth:** Owner or admin.
### Update Task
```
PUT /tasks/:id
```
Partial update. All fields from create are accepted.
**Auth:** `tasks.create` permission, must be owner.
### Delete Task
```
DELETE /tasks/:id
```
**Auth:** `tasks.create` permission, must be owner.
### Run Now (Manual Trigger)
```
POST /tasks/:id/run
```
Immediately executes the task regardless of schedule. Creates a new
task run. Returns 409 if a run is already active.
**Auth:** `tasks.create` permission, must be owner.
### Kill Active Run
```
POST /tasks/:id/kill
```
Cancels the active run. Sets status to `cancelled`.
**Auth:** `tasks.create` permission, must be owner.
### List Runs
```
GET /tasks/:id/runs
```
Returns run history for the task, most recent first.
**Auth:** Owner or admin.
## Team Tasks
Team-scoped tasks visible to all team members, manageable by team admins.
### List Team Tasks (Member)
```
GET /teams/:teamId/tasks
```
Returns tasks scoped to the team. Read-only for members.
**Auth:** Team member.
### List Team Task Runs (Member)
```
GET /teams/:teamId/tasks/:id/runs
```
**Auth:** Team member.
### Create Team Task (Admin)
```
POST /teams/:teamId/tasks
```
Same shape as personal task creation. Team scope is injected automatically.
**Auth:** `tasks.create` permission, team admin.
### Update/Delete/Run/Kill Team Task
```
PUT /teams/:teamId/tasks/:id
DELETE /teams/:teamId/tasks/:id
POST /teams/:teamId/tasks/:id/run
POST /teams/:teamId/tasks/:id/kill
```
**Auth:** `tasks.create` permission, team admin.
## Admin Tasks
Platform-wide task management.
### List All Tasks
```
GET /admin/tasks
```
Returns all tasks across all users and teams.
**Auth:** Platform admin.
### Admin Run/Kill/Delete
```
POST /admin/tasks/:id/run
POST /admin/tasks/:id/kill
DELETE /admin/tasks/:id
```
**Auth:** Platform admin.
## Task Object
```json
{
"id": "uuid",
"owner_id": "uuid",
"team_id": "uuid|null",
"name": "Morning News Digest",
"description": "...",
"scope": "personal|team|global",
"task_type": "prompt|workflow",
"persona_id": "uuid|null",
"model_id": "claude-sonnet-4-20250514",
"system_prompt": "...",
"user_prompt": "...",
"workflow_id": "uuid|null",
"tool_grants": ["web_search", "url_fetch"],
"schedule": "@daily",
"timezone": "America/New_York",
"is_active": true,
"max_tokens": 4096,
"max_tool_calls": 10,
"max_wall_clock": 300,
"output_mode": "channel|note|webhook",
"output_channel_id": "uuid|null",
"webhook_url": "https://...",
"webhook_secret": "...",
"provider_config_id": "uuid|null",
"notify_on_complete": false,
"notify_on_failure": true,
"last_run_at": "...|null",
"next_run_at": "...|null",
"run_count": 42,
"created_at": "...",
"updated_at": "..."
}
```
## Task Run Object
```json
{
"id": "uuid",
"task_id": "uuid",
"channel_id": "uuid|null",
"status": "running|completed|failed|budget_exceeded|cancelled",
"started_at": "...",
"completed_at": "...|null",
"tokens_used": 1234,
"tool_calls": 3,
"wall_clock": 45,
"error": "...|null"
}
```
## Execution
The `TaskScheduler` is a background goroutine polling every 30 seconds.
1. Finds tasks where `next_run_at <= now` and `is_active = true`
2. Skips if an active run exists (`GetActiveRun` returns non-nil)
3. Creates or reuses a `service` channel (`output_channel_id`)
4. Persists the `user_prompt` as a message in the service channel
5. Runs `CoreToolLoop` (headless completion — same as streaming but no SSE)
6. Enforces budgets: `max_tokens`, `max_tool_calls`, `max_wall_clock`
7. On budget breach: status = `budget_exceeded`, owner notified
8. On completion: updates `last_run_at`, calculates `next_run_at`
**Provider resolution:** BYOK → team provider → global provider → routing
policy. If `tasks.personal_require_byok` is true, personal tasks that
don't have a BYOK provider fail at step 5.
## Webhooks
Tasks with `webhook_url` fire a POST on completion:
```json
{
"task_id": "uuid",
"run_id": "uuid",
"status": "completed|failed|budget_exceeded",
"output": "...",
"tokens_used": 1234,
"timestamp": "..."
}
```
Signed with HMAC-SHA256 using `webhook_secret` in the
`X-Switchboard-Signature` header. Retry: 3 attempts, exponential backoff.

177
docs/ICD/teams.md Normal file
View File

@@ -0,0 +1,177 @@
# Teams & Access Control
### My Teams
```
GET /teams/mine → { "teams": [...] }
```
Returns teams the current user is a member of.
### Team Administration
Team-admin-scoped routes (require `RequireTeamAdmin` middleware):
**Team CRUD (platform admin):**
```
GET /admin/teams → { "teams": [...] }
POST /admin/teams
GET /admin/teams/:id
PUT /admin/teams/:id
DELETE /admin/teams/:id
```
**Members:**
```
GET /admin/teams/:id/members → { "members": [...] }
POST /admin/teams/:id/members ← { "user_id", "role" }
PUT /admin/teams/:id/members/:memberId ← { "role" }
DELETE /admin/teams/:id/members/:memberId
```
**Team-scoped routes** (team admin, not platform admin):
```
GET /teams/:teamId/members
POST /teams/:teamId/members ← { "user_id", "role" }
PUT /teams/:teamId/members/:memberId
DELETE /teams/:teamId/members/:memberId
```
**Team Providers:**
```
GET /teams/:teamId/providers → { "configs": [...] }
POST /teams/:teamId/providers ← { "name", "provider", "endpoint", "api_key", ... }
PUT /teams/:teamId/providers/:id
DELETE /teams/:teamId/providers/:id
GET /teams/:teamId/providers/:id/models → { "models": [...] }
```
**Team Models:**
```
GET /teams/:teamId/models → { "models": [...] }
```
Available models for this team (global + team-scoped).
**Team Personas:** See §4.2.
**Team Roles:**
```
GET /teams/:teamId/roles → { "roles": [...] }
PUT /teams/:teamId/roles/:role ← { "permissions": {...} }
DELETE /teams/:teamId/roles/:role
```
**Team Audit:**
```
GET /teams/:teamId/audit → paginated audit log
GET /teams/:teamId/audit/actions → { "actions": [...] } (distinct action types)
```
**Team Usage:**
```
GET /teams/:teamId/usage → { "totals": {...}, "results": [...] }
```
### Groups & Resource Grants
Groups are ACL containers that decouple access from team membership.
A resource grant controls who can see a Persona or KB beyond its base
scope.
**My Groups:**
```
GET /groups/mine → { "groups": [...] }
```
**Admin Group CRUD:**
```
GET /admin/groups → { "groups": [...] }
POST /admin/groups ← { "name", "scope", "team_id" }
GET /admin/groups/:id
PUT /admin/groups/:id
DELETE /admin/groups/:id
```
**Group Members:**
```
GET /admin/groups/:id/members → { "members": [...] }
POST /admin/groups/:id/members ← { "user_id" }
DELETE /admin/groups/:id/members/:userId
```
**Resource Grants:**
```
GET /admin/grants/:type/:id → { "grant": {...} }
PUT /admin/grants/:type/:id ← { "grant_type": "team_only|global|groups", "group_ids": [...] }
DELETE /admin/grants/:type/:id
```
`:type` is `persona` or `kb`. `:id` is the resource ID.
Grant types:
| `grant_type` | Visibility |
|--------------|-----------|
| `team_only` | Only the owning team |
| `global` | All authenticated users |
| `groups` | Members of specified groups |
### Permissions
Groups carry fine-grained permissions. Permission resolution:
union of all group permissions + Everyone group permissions.
**List all permissions:**
```
GET /admin/permissions → { "permissions": ["model.use", "kb.read", ...] }
```
**Get user's effective permissions:**
```
GET /admin/users/:id/permissions → { "permissions": [...], "groups": [...] }
```
Returns the resolved permission set and contributing groups.
**Group permission fields** (set via `PUT /admin/groups/:id`):
```json
{
"permissions": ["model.use", "kb.read", "channel.create"],
"token_budget_daily": 100000,
"token_budget_monthly": 3000000,
"allowed_models": ["claude-sonnet-4-20250514", "gpt-4o"]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `permissions` | string[] | Permission constants granted to members |
| `token_budget_daily` | int/null | Daily token ceiling (NULL = unlimited) |
| `token_budget_monthly` | int/null | Monthly token ceiling (NULL = unlimited) |
| `allowed_models` | string[]/null | Model allowlist (NULL = unrestricted) |
**Everyone group:** System-seeded group (ID `00000000-0000-0000-0000-000000000001`)
whose permissions apply to all authenticated users implicitly (no membership
row needed). Editable by admins, cannot be deleted (`source=system`).
**RequirePermission middleware:** Routes gated by permission check user's
resolved permission set. Returns 403 if the required permission is not present.
See [enums.md](enums.md) for the full permission constant list.

146
docs/ICD/utility.md Normal file
View File

@@ -0,0 +1,146 @@
# Export & Utility
### Health Check
```
GET /health → { "status": "ok" }
```
Public, no auth. Also available at `/api/v1/health`.
### Export
Convert markdown to PDF or DOCX via pandoc:
```
POST /export
```
```json
{
"content": "# My Document\n\nBody text...",
"format": "pdf|docx",
"filename": "my-document"
}
```
Returns the binary file with appropriate Content-Type. Requires
`pandoc` in the container (available in the unified and backend
Docker images).
---
## 17b. Files & Storage
All binary content in Chat Switchboard flows through a single
**ObjectStore** abstraction backed by PVC (filesystem) or S3. All
file metadata lives in one `files` table. The old `attachments`
table is dropped.
### Storage Backend
| Operation | Description |
|-----------|-------------|
| `Put(key, reader, size, contentType)` | Store a blob |
| `Get(key)` → reader, size, contentType | Retrieve a blob |
| `Delete(key)` | Remove a blob |
| `DeletePrefix(prefix)` | Bulk remove (channel deletion) |
| `Exists(key)` | Check without reading |
| `Healthy()` | Backend health check |
| `Stats()` | Aggregate metrics |
Backends: `pvc` (`STORAGE_PATH=/data/storage`) or `s3`
(`S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`).
### Key Namespacing
| Prefix | Pattern | What |
|--------|---------|------|
| `attachments/` | `attachments/{channel_id}/{file_id}_{filename}` | User uploads + AI outputs (legacy prefix retained for existing blobs) |
| `kb/` | `kb/{kb_id}/{doc_id}_{filename}` | Knowledge base documents |
| `exports/` | `exports/{user_id}/{export_id}_{filename}` | Chat/data exports |
| `workspace/` | `workspace/{workspace_id}/{path}` | Workspace files |
### File Object
```sql
CREATE TABLE files (
id UUID PRIMARY KEY,
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
origin TEXT NOT NULL DEFAULT 'user_upload'
CHECK (origin IN ('user_upload','tool_output','system')),
filename TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size_bytes BIGINT NOT NULL DEFAULT 0,
storage_key TEXT NOT NULL,
display_hint TEXT NOT NULL DEFAULT 'download'
CHECK (display_hint IN ('inline','download','thumbnail')),
extracted_text TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
**Origin:**
| Value | Who creates | Examples |
|-------|-------------|---------|
| `user_upload` | User via upload UI | PDF, image, doc attached to message |
| `tool_output` | Tool execution during completion | DALL-E image, video gen, code artifact |
| `system` | Platform | Export, avatar stored as file |
**Display hint:**
| Value | Frontend behavior |
|-------|-------------------|
| `inline` | Render in message stream (images, video, audio, HTML) |
| `download` | Filename + size + download button |
| `thumbnail` | Thumbnail preview, click to expand |
### Tool Output Flow
1. Tool executes (image gen, video render, etc.)
2. Tool writes blob via `ObjectStore.Put`
3. Tool creates file record: `POST /files` with `origin: "tool_output"`, `message_id` = current assistant message
4. Tool result includes `file_id` reference
5. WebSocket emits `file.created` event:
```json
{
"event": "file.created",
"channel_id": "uuid",
"message_id": "uuid",
"file": { ...file object... }
}
```
6. Frontend renders inline based on `display_hint` + `content_type`
The event fires during streaming so the user sees the artifact
before the completion finishes.
### Content Type → Display Hint Defaults
| Content Type | Default Hint | Rendering |
|-------------|-------------|-----------|
| `image/*` | `inline` | `<img>` in message, lightbox on click |
| `video/*` | `inline` | `<video>` player |
| `audio/*` | `inline` | `<audio>` player |
| `text/html` | `inline` | Sandboxed iframe |
| `application/pdf` | `thumbnail` | Thumb + PDF viewer on click |
| `text/*`, `application/json` | `inline` | Syntax-highlighted code block |
| Everything else | `download` | Filename + download button |
Tools can override the default hint explicitly.
### File Manager (Future Surface)
A user-scoped file browser backed by `GET /files?page=1&per_page=50`.
Displays all files owned by the user across all channels, grouped by
channel or date. Supports download and delete. Deleting a file that
is referenced by a message replaces the inline render with a
"file deleted" placeholder.

219
docs/ICD/websocket.md Normal file
View File

@@ -0,0 +1,219 @@
# WebSocket Protocol
Real-time bidirectional event bus. Single connection per client.
### Connection
```
ws://{host}{BASE_PATH}/ws?token={access_token}
```
**Lifecycle:**
- Server sends `ping` every 54 seconds
- Client must respond with `pong` within 60 seconds or disconnection
- Max message size: 4 KB
- Write deadline: 10 seconds
### Event Envelope
All messages are JSON:
```json
{
"type": "event.type",
"payload": { ... }
}
```
### Room Subscription
Clients subscribe to rooms for scoped event delivery:
```json
{ "type": "subscribe", "payload": { "room": "channel:uuid" } }
{ "type": "unsubscribe", "payload": { "room": "channel:uuid" } }
```
### Event Routing Table
| Prefix | Direction | Description |
|--------|-----------|-------------|
| `channel.created` | → client | New channel created |
| `channel.updated` | → client | Channel metadata changed |
| `channel.deleted` | → client | Channel removed |
| `message.created` | → client | New message in subscribed channel |
| `message.updated` | → client | Message content changed |
| `message.deleted` | → client | Message removed |
| `cursor.updated` | → client | Branch cursor moved |
| `typing.start` | ↔ both | Participant started typing (payload includes `participant_id`, `participant_type`) |
| `typing.stop` | ↔ both | Typing ended |
| `participant.joined` | → client | Participant added to channel |
| `participant.left` | → client | Participant removed from channel |
| `participant.updated` | → client | Participant role changed |
| `presence.update` | → client | Participant online/idle/offline status change |
| `model.changed` | → client | Channel model roster changed |
| `persona.updated` | → client | Persona metadata changed |
| `kb.updated` | → client | Knowledge base changed |
| `kb.document.status` | → client | Document processing status update |
| `note.created` | → client | Note created |
| `note.updated` | → client | Note content changed |
| `note.deleted` | → client | Note removed |
| `notification.new` | → client | New notification |
| `notification.read` | → client | Notification marked read |
| `memory.extracted` | → client | New memory extracted |
| `memory.status` | → client | Memory approved/rejected |
| `role.fallback` | → client | Role fallback triggered |
| `workspace.updated` | → client | Workspace state changed |
| `project.updated` | → client | Project metadata changed |
| `extension.updated` | → client | Extension config changed |
| `settings.updated` | → client | Global settings changed |
| `user.updated` | → client | User profile changed |
| `file.created` | → client | File uploaded or generated (§17b.4) |
| `file.deleted` | → client | File removed |
Direction: `→ client` = server-to-client only, `← client` = client-to-server
only, `↔ both` = bidirectional.
---
## 19. Appendix: Enums
### Channel Types
`direct` (single-user), `group` (multi-user/multi-model), `workflow` (staged)
### Participant Types
`user`, `persona`, `session`
### Participant Roles
`owner`, `member`, `observer`
### Presence Statuses
`online`, `idle`, `offline`
### Message Roles
`user`, `assistant`, `system`, `tool`
### Scopes
`personal`, `team`, `global`
### Visibility (model catalog)
`enabled`, `disabled`, `team`
### User Roles
`admin`, `user`
### Team Member Roles
`admin`, `member`
### Provider Types
`openai`, `anthropic`, `openrouter`, `venice` (extensible via registry)
### Model Types
`chat`, `embedding`, `image`
### Memory Scopes
`user`, `persona`
### Memory Statuses
`pending`, `approved`, `rejected`
### Workspace Owner Types
`user`, `project`
### Workspace Statuses
`active`, `archived`
### Index Statuses
`pending`, `indexing`, `ready`, `error`
### KB Document Statuses
`pending`, `chunking`, `embedding`, `ready`, `error`
### Provider Health Statuses
`healthy`, `degraded`, `down`
### Routing Policy Types
`provider_prefer`, `team_route`, `cost_limit`, `model_alias`
### Extension Tiers
`browser` (implemented), `starlark` (future), `sidecar` (future)
### Grant Types
`team_only`, `global`, `groups`
### Resource Grant Scopes
`persona`, `kb`
### Notification Types
`role.fallback`, `memory.extracted`, `system.announcement`, plus
extensible via notification service.
### Git Auth Types
`https_pat`, `https_basic`, `ssh_key`
### Policies
| Key | Default | Description |
|-----|---------|-------------|
| `allow_registration` | `"true"` | Allow new user self-registration |
| `allow_user_byok` | `"true"` | Allow users to add personal API keys |
| `allow_user_personas` | `"true"` | Allow users to create personal Personas |
| `allow_team_providers` | `"true"` | Allow team admins to configure team providers |
| `kb_direct_access` | `"true"` | Show KB picker in channel (false = strict enterprise mode) |
| `require_email` | `"false"` | Require email on registration |
| `default_system_prompt` | `""` | Injected into all conversations (admin override) |
---
## Page Routes (Non-API)
Server-rendered Go template surfaces. Not REST endpoints — these
return HTML pages.
| Route | Surface | Description |
|-------|---------|-------------|
| `/login` | Login | Standalone login/register page |
| `/` | Chat | Main chat interface |
| `/chat/:chatID` | Chat | Chat with specific channel loaded |
| `/editor` | Editor | Workspace file editor |
| `/editor/:wsId` | Editor | Editor with specific workspace |
| `/notes` | Notes | Notes interface |
| `/notes/:noteId` | Notes | Notes with specific note loaded |
| `/admin` | Admin | Platform administration |
| `/admin/:section` | Admin | Admin with specific section |
| `/settings` | Settings | User settings |
| `/settings/:section` | Settings | Settings with specific section |
All page routes (except `/login`) require authentication via
`AuthOrRedirect` middleware (reads `sb_token` cookie). Admin routes
additionally require `RequireAdminPage()`.
**Dynamic surface routing** for admin-installed extension surfaces is
not yet implemented. The current five surfaces are hardcoded in Go
route registration. Future: extension manifests declare surface routes,
the page engine dynamically registers them with appropriate data
loaders. See EXTENSIONS.md for the design direction.

472
docs/ICD/workflows.md Normal file
View File

@@ -0,0 +1,472 @@
# Workflows
Team-owned, stage-based process execution. The channel is the runtime —
personas drive each stage, and the existing tool/notes infrastructure
handles structured data collection.
## Definitions
### List Workflows
```
GET /workflows?team_id=...
```
Returns `{ "data": [...] }`. If `team_id` is provided, scoped to that team.
Otherwise returns global (team_id IS NULL) workflows.
**Auth:** Authenticated.
### Create Workflow
```
POST /workflows
```
```json
{
"name": "Customer Intake",
"slug": "customer-intake",
"description": "Collect customer info and route to support",
"entry_mode": "public_link",
"team_id": "uuid|null",
"branding": { "accent_color": "#2563eb", "tagline": "Welcome" },
"retention": { "mode": "archive", "delete_after_days": 90 }
}
```
`slug` auto-generated from `name` if omitted. Must be 2-64 chars,
lowercase alphanumeric and hyphens. Unique within scope (team or global).
`entry_mode`: `public_link` (visitors can start via URL) or `team_only`
(only team members can start instances).
**Auth:** `workflow.create` permission required.
**Response:** 201 with workflow object.
### Get Workflow
```
GET /workflows/:id
```
Returns workflow with its stages included in the `stages` array.
**Auth:** Authenticated.
### Update Workflow
```
PATCH /workflows/:id
```
Partial update. Accepted fields: `name`, `description`, `branding`,
`entry_mode`, `is_active`, `on_complete`, `retention`, `webhook_url`.
Any edit increments the `version` number.
**Auth:** `workflow.create` permission required.
### Delete Workflow
```
DELETE /workflows/:id
```
Cascades: deletes all stages, versions, and active instances.
**Auth:** `workflow.create` permission required.
### Workflow Object
```json
{
"id": "uuid",
"team_id": "uuid|null",
"name": "Customer Intake",
"slug": "customer-intake",
"description": "...",
"branding": { "accent_color": "#2563eb", "logo_url": "...", "tagline": "..." },
"entry_mode": "public_link|team_only",
"is_active": true,
"version": 2,
"on_complete": { "action": "start_workflow", "target_slug": "follow-up", "data_map": {"name": "customer_name"} },
"retention": { "mode": "archive|delete", "delete_after_days": 90 },
"webhook_url": "https://...",
"webhook_secret": "...",
"created_by": "uuid",
"created_at": "...",
"updated_at": "...",
"stages": [...]
}
```
## Stages
Ordered steps within a workflow. Each stage has a driving persona and
optional human assignment team.
### List Stages
```
GET /workflows/:id/stages
```
Returns `{ "data": [...] }` ordered by `ordinal`.
**Auth:** Authenticated.
### Create Stage
```
POST /workflows/:id/stages
```
```json
{
"name": "Collect Info",
"ordinal": 0,
"persona_id": "uuid|null",
"assignment_team_id": "uuid|null",
"form_template": { "fields": ["name", "email", "issue"] },
"history_mode": "full|summary|fresh",
"auto_transition": false,
"transition_rules": { "auto_assign": "round_robin" }
}
```
`history_mode`: what chat history the next stage sees. `full` = complete,
`summary` = utility-role summary, `fresh` = clean slate.
`form_template`: injected into the completion system prompt as guidance
for what the persona should collect. Not rendered as UI — the LLM is
told what to ask for.
`transition_rules.auto_assign`: `round_robin` assigns to team members
in rotation. Null = unassigned (manual claim).
**Auth:** `workflow.create` permission required.
### Update Stage
```
PUT /workflows/:id/stages/:sid
```
Full replacement of stage fields.
**Auth:** `workflow.create` permission required.
### Delete Stage
```
DELETE /workflows/:id/stages/:sid
```
**Auth:** `workflow.create` permission required.
### Reorder Stages
```
PATCH /workflows/:id/stages/reorder
```
```json
{ "ordered_ids": ["stage-uuid-2", "stage-uuid-1", "stage-uuid-3"] }
```
Sets ordinals based on array position.
**Auth:** `workflow.create` permission required.
### Stage Object
```json
{
"id": "uuid",
"workflow_id": "uuid",
"ordinal": 0,
"name": "Collect Info",
"persona_id": "uuid|null",
"assignment_team_id": "uuid|null",
"form_template": {},
"history_mode": "full",
"auto_transition": false,
"transition_rules": {},
"created_at": "..."
}
```
## Versions (Immutable Snapshots)
### Publish
```
POST /workflows/:id/publish
```
Creates an immutable snapshot of the current workflow definition, stages,
and persona tool grants. Running instances are pinned to their version.
Returns 201 with the version object. Returns 409 if the current version
number has already been published (edit the workflow to increment first).
**Auth:** `workflow.create` permission required.
### Get Version
```
GET /workflows/:id/versions/:version
```
Returns the version snapshot.
**Auth:** Authenticated.
### Version Object
```json
{
"id": "uuid",
"workflow_id": "uuid",
"version_number": 2,
"snapshot": { "workflow": {...}, "stages": [...] },
"created_at": "..."
}
```
## Instances
A workflow **instance** is a channel with `type=workflow`. The channel's
`workflow_id`, `workflow_version`, `current_stage`, `stage_data`, and
`workflow_status` columns track instance state.
### Start Instance
```
POST /workflows/:id/start
```
Creates a new workflow channel, sets `current_stage=0`, pins the latest
published version. Returns the channel object.
**Auth:** Authenticated (for team members). Visitors use the entry API.
### Get Instance Status
```
GET /channels/:id/workflow/status
```
```json
{
"workflow_id": "uuid",
"workflow_version": 2,
"current_stage": 1,
"stage_data": { "name": "Jane", "email": "jane@co.com" },
"status": "active|completed|stale",
"last_activity_at": "..."
}
```
**Auth:** Authenticated.
### Advance Stage
```
POST /channels/:id/workflow/advance
```
```json
{ "data": { "name": "Jane", "email": "jane@co.com" } }
```
Merges `data` into the channel's accumulated stage data and moves to
the next stage. If the current stage is the last, marks the workflow
as `completed` and sets `ai_mode=off`.
On completion, triggers `on_complete` chaining and webhook delivery
if configured.
**Auth:** Authenticated.
### Reject (Go Back)
```
POST /channels/:id/workflow/reject
```
```json
{ "reason": "Missing required field: email" }
```
Moves back one stage. Cannot go below stage 0. `reason` is required —
persisted as a system message in the channel history.
**Auth:** Authenticated.
### Workflow Advance Tool
The `workflow_advance` tool is available to personas during workflow
completions. When the LLM determines enough data has been collected,
it calls this tool to trigger the stage transition programmatically.
The tool has a `RequireWorkflow` predicate — it only appears in
workflow channels.
## Assignments
Human review queue for workflow stages that have an `assignment_team_id`.
### List My Assignments
```
GET /workflow-assignments/mine
```
Returns assignments where `assigned_to` is the current user or
`status=unassigned` for the user's teams.
**Auth:** Authenticated.
### Claim Assignment
```
POST /workflow-assignments/:id/claim
```
Sets `assigned_to` to the current user, status to `claimed`.
Returns 409 if the assignment is already claimed or not found.
**Auth:** Authenticated.
### Complete Assignment
```
POST /workflow-assignments/:id/complete
```
Marks assignment as `completed`. Does NOT auto-advance the stage —
the human reviews and manually advances or the persona tool does it.
Returns 409 if the assignment is not in `claimed` state.
**Auth:** Authenticated.
### List Team Assignments
```
GET /teams/:teamId/assignments?status=unassigned
```
Assignments for the team, filtered by `status` (default: `unassigned`).
Valid values: `unassigned`, `claimed`, `completed`.
**Auth:** Authenticated (team member).
### Assignment Object
```json
{
"id": "uuid",
"channel_id": "uuid",
"stage": 1,
"team_id": "uuid",
"assigned_to": "uuid|null",
"status": "unassigned|claimed|completed",
"created_at": "...",
"claimed_at": "...|null",
"completed_at": "...|null"
}
```
## Visitor Entry
Public-facing workflow entry for unauthenticated visitors.
### Landing Page
```
GET /w/:id
GET /w/:id/:slug
```
Server-rendered branded page. Shows workflow name, tagline, branding
colors, and a "Start" button. If the visitor has an existing session
cookie for this workflow, resumes the conversation.
Not an API endpoint — returns HTML.
### Start Visitor Session
```
POST /api/v1/workflow-entry/:scope/:slug
```
No request body. Display name is auto-generated (`Visitor`,
`Visitor 2`, etc.).
`:scope` is a team ID or `global`. Creates a session participant,
creates a workflow channel, sets `allow_anonymous=true`. Returns:
```json
{
"channel_id": "uuid",
"session_id": "uuid",
"redirect_to": "/w/channel-uuid"
}
```
The session token is set as a cookie (`sb_session`) for subsequent
requests. `redirect_to` is the workflow chat surface URL.
### Visitor Message/Completion
Visitors interact through the workflow channel using session-scoped routes:
```
POST /api/v1/w/:id/messages
GET /api/v1/w/:id/messages
POST /api/v1/w/:id/completions
```
These use `AuthOrSession` middleware — the session cookie identifies
the visitor without requiring a JWT.
## Staleness Sweep
Background goroutine. Checks active workflow instances for staleness
(no activity within `WORKFLOW_STALE_HOURS`, default 72). Stale instances
are marked `workflow_status=stale`. Retention policy then applies:
`archive` keeps the channel, `delete` removes it after `delete_after_days`.
## On-Complete Chaining
When a workflow completes and has `on_complete` configured:
```json
{
"on_complete": {
"action": "start_workflow",
"target_slug": "follow-up-survey",
"data_map": { "name": "customer_name", "case_id": "case_id" }
}
}
```
`action` must be `start_workflow`. `target_slug` identifies the workflow
to chain into (looked up in the same team scope). `data_map` is a flat
key remapping from source stage data keys to target initial data keys.
If `data_map` is empty/omitted, all stage data is passed through as-is.
The system starts a new instance of the target workflow, passing mapped
stage data as initial context. Webhook delivery (if configured) fires
before chaining.
## WebSocket Events
| Event | When |
|-------|------|
| `workflow.advanced` | Stage transition (includes new stage info) |
| `workflow.completed` | Workflow finished |
| `workflow.assigned` | New assignment created |
| `workflow.claimed` | Assignment claimed by user |

137
docs/ICD/workspaces.md Normal file
View File

@@ -0,0 +1,137 @@
# Workspaces & Git
The **Workspace** is a virtual filesystem for the editor surface.
Each workspace has a root directory, file operations, optional Git
integration, and full-text indexing for code search.
### Workspace CRUD
```
GET /workspaces → { "data": [...] }
POST /workspaces ← { "name", "owner_type", "owner_id" }
GET /workspaces/:id → workspace object
PATCH /workspaces/:id ← partial update
DELETE /workspaces/:id
```
Workspace object:
```json
{
"id": "uuid",
"name": "my-project",
"owner_type": "user|project",
"owner_id": "uuid",
"status": "active|archived",
"storage_bytes": 1048576,
"file_count": 42,
"created_at": "...",
"updated_at": "..."
}
```
### File Operations
All paths are relative to the workspace root.
```
GET /workspaces/:id/files?path=/ → { "files": [entries] }
GET /workspaces/:id/files/read?path=/a.md → { "content": "...", "size": 123 }
PUT /workspaces/:id/files/write ← { "path": "/a.md", "content": "..." }
DELETE /workspaces/:id/files/delete ← { "path": "/a.md" }
POST /workspaces/:id/files/mkdir ← { "path": "/src" }
```
File entry:
```json
{
"name": "main.go",
"path": "/src/main.go",
"is_dir": false,
"size": 4096,
"modified": "..."
}
```
### Archive
**Download** (tar.gz of entire workspace):
```
GET /workspaces/:id/archive/download
```
**Upload** (restore from tar.gz):
```
POST /workspaces/:id/archive/upload
Content-Type: multipart/form-data
```
### Reconcile, Stats, Index
**Reconcile** (sync filesystem state with DB metadata):
```
POST /workspaces/:id/reconcile
```
**Stats:**
```
GET /workspaces/:id/stats
```
Returns `{ "file_count", "storage_bytes", "indexed_files", ... }`.
**Index status:**
```
GET /workspaces/:id/index-status
```
Returns indexing progress for full-text search.
### Git Integration
All Git operations are workspace-scoped.
```
POST /workspaces/:id/git/clone ← { "url", "branch", "credential_id" }
POST /workspaces/:id/git/pull
POST /workspaces/:id/git/push
GET /workspaces/:id/git/status → { "files": [{ "path", "status" }] }
GET /workspaces/:id/git/diff → { "diff": "..." }
POST /workspaces/:id/git/commit ← { "message", "files": [...] }
GET /workspaces/:id/git/log → { "commits": [...] }
GET /workspaces/:id/git/branches → { "branches": [...], "current": "main" }
POST /workspaces/:id/git/checkout ← { "branch": "feature-x" }
```
### Git Credentials
User-owned, encrypted credential storage for Git operations.
Encrypted with the user's UEK (same as BYOK keys — admins cannot
recover).
```
POST /git-credentials ← { "name", "auth_type", "token"|"username"+"password"|"ssh_key" }
GET /git-credentials → { "data": [GitCredentialSummary] }
DELETE /git-credentials/:id
```
`auth_type`: `https_pat`, `https_basic`, or `ssh_key`.
Summary response (never exposes encrypted data):
```json
{
"id": "uuid",
"name": "GitHub PAT",
"auth_type": "https_pat",
"created_at": "..."
}
```
---