Changeset 0.28.0.1 (#173)
This commit is contained in:
2621
docs/ICD-API.md
2621
docs/ICD-API.md
File diff suppressed because it is too large
Load Diff
@@ -1,265 +0,0 @@
|
||||
# ICD Audit: API Contract vs ROADMAP / ARCHITECTURE
|
||||
|
||||
**Date:** 2026-03-04 | **Baseline:** v0.23.0
|
||||
**Scope:** Cross-reference ICD-API.md against ROADMAP.md, ARCHITECTURE.md, and live codebase.
|
||||
**Status:** §2 (naming) fully resolved — preset→persona rename complete across all JS, templates, CSS, Go handlers, and ICD. §3 (cleanup) resolved. §4.1 (chat_id alias) resolved. §4.2 (legacy list messages) open. ICD updated to target state including multi-participant channels.
|
||||
|
||||
---
|
||||
|
||||
## 1. ARCHITECTURE.md — Staleness Report
|
||||
|
||||
ARCHITECTURE.md is stamped **v0.20** — six minor versions behind. The following sections are materially stale:
|
||||
|
||||
### 1.1 Frontend File Structure (§ Frontend Architecture)
|
||||
|
||||
ARCHITECTURE lists 19 JS files. Codebase has **34**. Fifteen files are undocumented:
|
||||
|
||||
| Missing from ARCHITECTURE | Added In | Purpose |
|
||||
|---------------------------|----------|---------|
|
||||
| `admin-scaffold.js` | v0.22.7 | Admin section DOM scaffolding |
|
||||
| `channel-models.js` | v0.20.0 | Multi-model channel roster UI |
|
||||
| `chat-pane.js` | v0.22.7 | Reusable ChatPane component |
|
||||
| `editor-mode.js` | v0.21.5 | Editor surface (listed but not in file tree) |
|
||||
| `extensions.js` | v0.11.0 | Extension loader |
|
||||
| `notification-prefs.js` | v0.20.0 | Notification preference UI |
|
||||
| `notifications.js` | v0.20.0 | Bell dropdown + badge |
|
||||
| `pages.js` | v0.22.5 | Page-level auth + routing |
|
||||
| `pages-splash.js` | v0.22.7 | Login/register surface |
|
||||
| `panels.js` | v0.18.1 | Side panel architecture |
|
||||
| `persona-kb.js` | v0.17.0 | Persona-KB binding UI |
|
||||
| `repl.js` | v0.21.3 | REPL surface |
|
||||
| `surface-nav.js` | v0.22.8 | Surface navigation overrides |
|
||||
| `tools-toggle.js` | v0.13.1 | Per-tool enable/disable UI |
|
||||
| `ui-format.js` | v0.22.7 | Message formatting helpers |
|
||||
| `ui-primitives.js` | v0.10.5 | Shared render primitives |
|
||||
| `ui-primitives-additions.js` | v0.22.7 | Toast, badge, icon extensions |
|
||||
|
||||
ARCHITECTURE also lists `knowledge.js` — **renamed** to `knowledge-ui.js`.
|
||||
|
||||
### 1.2 SPA Shell References
|
||||
|
||||
ARCHITECTURE §Deployment Modes still says:
|
||||
|
||||
> "The frontend entrypoint (`docker-entrypoint-fe.sh`) handles `BASE_PATH` injection into `index.html`"
|
||||
|
||||
And the file tree lists:
|
||||
|
||||
> `├── index.html # Single-page app shell`
|
||||
|
||||
Both are wrong since v0.22.6. `index.html` is now a 16-line redirect stub. The real entry points are Go-rendered surface templates. The entrypoint now generates `proxy_pass` blocks to the backend for surface routes.
|
||||
|
||||
### 1.3 Deployment Diagram
|
||||
|
||||
The split-deployment description is functionally correct but the mechanism is wrong. Frontend no longer serves an SPA — it reverse-proxies page routes to the backend, which renders Go templates. The diagram should show the backend serving both API and HTML.
|
||||
|
||||
### 1.4 Backward Compatibility Section
|
||||
|
||||
The section says:
|
||||
|
||||
> "JSON field rename: `api_config_id` → `provider_config_id`... Frontend updated to match."
|
||||
|
||||
This is now ancient history (v0.9). The rename is complete everywhere. This section should either be removed or reduced to a single sentence noting historical context.
|
||||
|
||||
### 1.5 Missing Sections
|
||||
|
||||
ARCHITECTURE has **no documentation** for:
|
||||
|
||||
- Go template engine (`server/pages/`)
|
||||
- Surface architecture (5 surfaces: chat, editor, notes, admin, settings)
|
||||
- Template components (`model-select`, `team-select`, `file-upload`, `chat-pane`)
|
||||
- Page data loaders (`loaders.go`)
|
||||
- Provider Extensions / hook system (v0.22.1)
|
||||
- Routing policies (v0.22.2)
|
||||
- Export system (v0.22.4)
|
||||
- Git integration (v0.21.4)
|
||||
- The `treepath/` package (cursor, path, siblings)
|
||||
|
||||
**Recommendation:** Bump to v0.22 and rewrite §Frontend Architecture, §Deployment Modes, and §Backward Compatibility. Add §Template Engine, §Surface Architecture, §Provider Extensions sections.
|
||||
|
||||
---
|
||||
|
||||
## 2. ROADMAP.md — Housekeeping
|
||||
|
||||
### 2.1 Naming Inconsistency: Presets vs Personas
|
||||
|
||||
The ROADMAP uses "Persona" consistently. The API routes use `/personas`. The Go handler type is `PersonaHandler`. The JSON response sends **both** `"personas"` and `"presets"` keys (backward compat). The frontend reads the `presets` key exclusively.
|
||||
|
||||
This is a naming barnacle that bleeds into every layer:
|
||||
|
||||
| Layer | Name Used |
|
||||
|-------|-----------|
|
||||
| ROADMAP / ARCHITECTURE | Persona |
|
||||
| Go types | `Persona`, `PersonaPatch` |
|
||||
| Go handler | `PersonaHandler` |
|
||||
| API routes | `/personas`, `/admin/personas` |
|
||||
| JSON response keys | `"personas"` AND `"presets"` (dual) |
|
||||
| Frontend JS | `presets` (reads `data.presets`) |
|
||||
| UI labels | Mixed ("Preset" in some places, "Persona" in others) |
|
||||
|
||||
**Recommendation:** Pick one. The domain concept is "Persona" (it carries identity, system prompt, model, KB bindings — it's not just a preset). Two options:
|
||||
|
||||
- **Option A — Route rename**: `/personas` → `/personas`. Update FE. Remove dual JSON keys. This is a clean break and pre-1.0 is the time to do it.
|
||||
- **Option B — Accept "presets" everywhere**: Rename Go types to `Preset`, update docs. Simpler but loses semantic precision.
|
||||
|
||||
### 2.2 `chat_id` Backward Compat Alias
|
||||
|
||||
`completionRequest` has:
|
||||
|
||||
```go
|
||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
||||
```
|
||||
|
||||
The frontend doesn't send `chat_id` anymore. This is dead code in the handler that adds 3 lines of aliasing logic. Safe to remove.
|
||||
|
||||
### 2.3 Go Field Name Barnacle: `APIConfigID`
|
||||
|
||||
Throughout `channels.go` and `completion.go`, the Go struct field is named `APIConfigID` with json tag `"provider_config_id"`. The JSON tag is correct, so this is invisible to the API — but it's a source of confusion for anyone reading the Go code. Rename the Go field to `ProviderConfigID` for consistency.
|
||||
|
||||
### 2.4 Dual JSON Keys on Persona List
|
||||
|
||||
Three `PersonaHandler` methods return:
|
||||
|
||||
```go
|
||||
gin.H{"personas": personas, "presets": personas}
|
||||
```
|
||||
|
||||
The `"presets"` key exists solely for backward compat that was needed in v0.9. The FE only reads `"presets"`. If we go with Option A (§2.1), remove the `"presets"` key. If Option B, remove the `"personas"` key. Either way, stop sending both.
|
||||
|
||||
### 2.5 Route Alias: `/models` → `/models/enabled`
|
||||
|
||||
`main.go` has:
|
||||
|
||||
```go
|
||||
protected.GET("/models/enabled", modelH.ListEnabledModels)
|
||||
protected.GET("/models", modelH.ListEnabledModels) // alias
|
||||
```
|
||||
|
||||
Plus the admin has its own `/admin/models`. Having `/models` as an alias for `/models/enabled` is confusing when `/admin/models` is a completely different thing. The ICD documents both, but one should go away. The FE uses `listEnabledModels()` → `/models/enabled`. The alias is unused.
|
||||
|
||||
### 2.6 ROADMAP v0.22.8 — Status vs Reality
|
||||
|
||||
The ROADMAP shows v0.22.8 as the next incomplete version with checkboxes for ChatPane Bridge, Theme Integration, Admin Surface, and Settings Surface. The last two sessions partially implemented some of these (admin scaffold, surface-nav, prototype overrides) but things broke and were rolled back/lost. The ROADMAP doesn't reflect the partial state — it still shows all items as unchecked.
|
||||
|
||||
**Recommendation:** After the ICD is finalized and the FE rebuild begins properly, update v0.22.8 checkboxes to reflect actual completion. Consider splitting v0.22.8 into smaller increments since it's become a catch-all.
|
||||
|
||||
---
|
||||
|
||||
## 3. ICD Gaps (things the backend implements but ICD doesn't fully cover)
|
||||
|
||||
### 3.1 Admin Models — Specific Endpoints
|
||||
|
||||
The ICD §24.5 lists admin model routes but doesn't document request/response shapes for:
|
||||
|
||||
- `POST /admin/models/fetch` — triggers provider sync, returns updated catalog
|
||||
- `PUT /admin/models/bulk` — bulk visibility updates (request shape)
|
||||
- `PUT /admin/models/:id` — single model update (what fields?)
|
||||
|
||||
**Fix:** Extract shapes from `admin.go` handler methods and add to ICD.
|
||||
|
||||
### 3.2 Admin Stats Response Shape
|
||||
|
||||
ICD §24.3 says "returns aggregate counts" but doesn't specify the shape. Should document:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": 42,
|
||||
"channels": 156,
|
||||
"messages": 12847,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Admin Global Config — configWithKey Shape
|
||||
|
||||
ICD §24.4 says admin configs include `has_key` flag but doesn't show the full shape. The actual `configWithKey` response differs from the user-facing `safeConfig` — it includes `config`, `headers`, `settings` objects.
|
||||
|
||||
### 3.4 Team Provider Models
|
||||
|
||||
`GET /teams/:teamId/providers/:id/models` is in `main.go` but not clearly called out in the ICD §18.2 team admin section.
|
||||
|
||||
### 3.5 Team Audit Endpoints
|
||||
|
||||
`GET /teams/:teamId/audit` and `GET /teams/:teamId/audit/actions` are registered but not in the ICD team section.
|
||||
|
||||
### 3.6 Knowledge Base — Missing Detail
|
||||
|
||||
ICD §14 doesn't document:
|
||||
|
||||
- `GET /channels/:id/knowledge-bases` response shape (array of `ChannelKB`)
|
||||
- `PUT /channels/:id/knowledge-bases` request shape
|
||||
- `GET /knowledge-bases-discoverable` vs `GET /knowledge-bases` distinction
|
||||
- KB document status progression: `pending → chunking → embedding → ready | error`
|
||||
|
||||
### 3.7 Export Endpoint Shape
|
||||
|
||||
ICD §21.2 has the right route but should note that `pandoc` must be available in the container. Also missing: supported format list beyond pdf/docx (the handler may accept more).
|
||||
|
||||
---
|
||||
|
||||
## 4. ICD Barnacles (things in the ICD that should be cleaned up)
|
||||
|
||||
### 4.1 `chat_id` Alias
|
||||
|
||||
The ICD §5.1 notes `chat_id` as a deprecated alias in the completion request. Once the dead code is removed from the handler, remove from ICD.
|
||||
|
||||
### 4.2 Legacy ListMessages
|
||||
|
||||
ICD §4.2 documents `GET /channels/:id/messages` as "Legacy/Debug." If it's truly not for frontend use, consider either removing the route or moving it to the admin namespace. Currently it's in the protected (user) namespace, which means any user can dump all messages (including branches) for their channels.
|
||||
|
||||
---
|
||||
|
||||
## 5. ROADMAP/ARCHITECTURE Reorganization Suggestions
|
||||
|
||||
### 5.1 ARCHITECTURE Should Reference the ICD
|
||||
|
||||
Instead of maintaining API route tables in multiple places, ARCHITECTURE should say "See ICD-API.md for the complete endpoint reference" and focus on:
|
||||
|
||||
- Design principles, scope model, resolution chains (what it does well today)
|
||||
- Package structure (update to v0.22)
|
||||
- Frontend architecture (rewrite for surfaces/templates)
|
||||
- Security model (mostly current)
|
||||
- New: Template engine, provider extensions, routing
|
||||
|
||||
### 5.2 ROADMAP Completed Versions — Collapse
|
||||
|
||||
The ROADMAP has 900+ lines of completed release details (v0.9 through v0.22.7). These are duplicated in CHANGELOG.md. The ROADMAP should collapse completed versions to one-liners (as it does for v0.9.x through v0.16.0) and push detailed history to CHANGELOG.
|
||||
|
||||
Currently v0.17.0 through v0.22.7 have full checkbox-level detail in the ROADMAP — that's useful while in-progress but noise once shipped. Collapse them.
|
||||
|
||||
### 5.3 Three-Document Model
|
||||
|
||||
Post-audit, the documentation should be:
|
||||
|
||||
| Document | Audience | Content |
|
||||
|----------|----------|---------|
|
||||
| `ARCHITECTURE.md` | Developers | Design principles, package structure, data model, resolution chains, security, deployment. **No API details.** |
|
||||
| `ICD-API.md` | FE developers, integrators | Complete API contract: routes, shapes, auth, streaming, WebSocket. |
|
||||
| `ROADMAP.md` | Stakeholders, contributors | Version plan, dependency graph, future features. Completed versions collapsed to one-liners with CHANGELOG refs. |
|
||||
|
||||
Future additions:
|
||||
|
||||
| Document | Audience | Content |
|
||||
|----------|----------|---------|
|
||||
| `ICD-SURFACE.md` | FE developers | Surface contract: templates, PageData, DOM IDs, JS globals, lifecycle. |
|
||||
| `ICD-EXTENSION.md` | Extension authors | Extension contract: manifest, tiers, hooks, tool bridge, asset serving. |
|
||||
|
||||
### 5.4 ROADMAP Dependency Graph — Update
|
||||
|
||||
The ASCII dependency graph stops at v0.22.7. The v0.22.8 → v0.23.0 → v0.24.0 path should be added.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cleanup Punch List
|
||||
|
||||
Priority-ordered actions that could all happen in a single commit:
|
||||
|
||||
| # | Action | Risk | Lines Changed |
|
||||
|---|--------|------|---------------|
|
||||
| 1 | Remove `chat_id` alias from `completionRequest` | None — FE doesn't use it | ~5 |
|
||||
| 2 | Rename Go field `APIConfigID` → `ProviderConfigID` in channels.go + completion.go | None — json tags unchanged | ~30 |
|
||||
| 3 | Pick persona vs preset naming, remove dual JSON keys | Low — FE reads one key | ~10 |
|
||||
| 4 | Remove `/models` alias (keep `/models/enabled` only) | Low — check FE uses | ~1 |
|
||||
| 5 | Bump ARCHITECTURE.md to v0.22, rewrite §Frontend | Medium — documentation | ~100 |
|
||||
| 6 | Collapse ROADMAP completed versions (v0.17–v0.22.7) to one-liners | None — CHANGELOG has detail | ~600 removed |
|
||||
| 7 | Add missing ICD shapes (§3 above) | None — documentation | ~80 added |
|
||||
| 8 | Update ICD to remove chat_id and document any cleanup from #1–#4 | None | ~10 |
|
||||
155
docs/ICD/README.md
Normal file
155
docs/ICD/README.md
Normal 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
198
docs/ICD/admin.md
Normal 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
175
docs/ICD/auth.md
Normal 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
711
docs/ICD/channels.md
Normal 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
250
docs/ICD/enums.md
Normal 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
34
docs/ICD/extensions.md
Normal 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
167
docs/ICD/knowledge.md
Normal 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
48
docs/ICD/memory.md
Normal 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
88
docs/ICD/models.md
Normal 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
105
docs/ICD/notes.md
Normal 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
44
docs/ICD/notifications.md
Normal 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
204
docs/ICD/personas.md
Normal 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
55
docs/ICD/profile.md
Normal 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
87
docs/ICD/projects.md
Normal 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
221
docs/ICD/providers.md
Normal 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
118
docs/ICD/surfaces.md
Normal 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
290
docs/ICD/tasks.md
Normal 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
177
docs/ICD/teams.md
Normal 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
146
docs/ICD/utility.md
Normal 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
219
docs/ICD/websocket.md
Normal 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
472
docs/ICD/workflows.md
Normal 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
137
docs/ICD/workspaces.md
Normal 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": "..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user