--- spec_name: worldtree-conversation-api version: "2.1" spec_date: "2026-07-23" worldtree_main_sha: "69d162d" worldtree_module_contract: "docs/contracts/conversation_api.contract.md" worldtree_spec: "docs/conversation-api-spec.md" supersedes: "worldtree-reference/conversation-api-spec.md (v1.0, 2026-04-15, pre-auth pre-extensions)" status: "current — pinned to Worldtree main 69d162d" covers: - "Heimdall auth (Phase B): API keys, scopes, bootstrap admin" - "Issue #110 umbrella + tier-1/tier-2 ships through 2026-05-07" - "Issues #112 (health), #113 (SSE resume), #115/#116 (key issue/rotate), #117 (rate limits)" - "Issue #118 (uploads), #119 (pending), #120 (session metadata), #121 (pagination), #122 (search)" - "Issue #123 (tool-events persistence), #127 (admin events), #129/#130/#131 (voice harness)" - "Issue #153 (transient characters)" - "#344 ADR-0012 model-role axis + role catalog" - "#188 agents.patch" - "#347 authored-history writes" - "W-4 agents-response role field (spec 1.2)" - "post-2.0 SSE events (worker_phase, awaiting_llm_first_token, affect_update)" - "GET /pending rename" deferred_v2x: - "#128 horizontal scaling, #132–#135 compliance hardening (TLS, FIPS, audit retention, log redaction)" - "STT inbound (split off from #129)" - "DeYoung 10-aspect persona splitting (#154)" --- # Worldtree Conversation API — SEA Client Interface Specification (v2.1) **Authoritative source:** Worldtree's [`docs/conversation-api-spec.md`](https://gitea.phasefinal.com/vh/Worldtree/src/branch/main/docs/conversation-api-spec.md) and [`docs/contracts/conversation_api.contract.md`](https://gitea.phasefinal.com/vh/Worldtree/src/branch/main/docs/contracts/conversation_api.contract.md). **This document:** SEA gateway-side reference. Read it as "here is what your gateway builds against." When the Worldtree contract or spec disagrees with this document, the Worldtree side wins — file a SEA-tracker issue cross-referencing the Worldtree commit that introduced the divergence. This v2 supersedes [`conversation-api-spec.md`](conversation-api-spec.md) (v1.0, 2026-04-15, pre-auth). v1 remains in this directory as a frozen historical reference. Do not consume v1 fields; pin to v2 going forward. --- ## 1. Base URL and transport ``` ${BASE} = http://{host}:{port} ``` Default: `http://127.0.0.1:8080`. All endpoints accept and return JSON unless otherwise noted. Streaming endpoints use Server-Sent Events (SSE) per W3C convention (`Content-Type: text/event-stream`, `data: ` per event, double-newline-terminated). Server entry point (Worldtree-side): ```bash python -m core.conversation_api --host 0.0.0.0 --port 8080 ``` Multiple per-tenant Worldtree instances per ADR-008 — SEA never assumes shared Worldtree. --- ## 2. Authentication Worldtree ships **Heimdall (Phase B)** as the auth layer. Two modes: ### 2.1 Authenticated mode (production) Set `WORLDTREE_BOOTSTRAP_ADMIN_KEY=wt_live_<32hex>` on the server before first start to provision the bootstrap admin user. After that, all endpoints require `Authorization: Bearer wt__<32hex>`. Production keys: `wt_live_<32hex>`. The `wt_test_` prefix is reserved but currently unused. Any other `wt__` prefix requires explicit Worldtree contract amendment per `INV-key-prefix`. ### 2.2 Dev / anonymous mode When `defaults.yaml → conversation_api.api_keys` is empty (or omitted), Heimdall runs in dev mode. All requests resolve to an anonymous SecurityContext. Useful for local development, integration tests. Production deployments MUST configure keys. ### 2.3 Heimdall scopes Each API key carries a tier (`anonymous` / `user` / `free` / `pro` / `admin`). Tiers map to scope sets in `config/policies.yaml`. `user` is the default tier auto-assigned by `POST /admin/keys` when the request body does not specify one (and is created if the target user does not yet exist); `free` and `pro` are subscription-tier slots reserved for future billing-plan distinctions. The scopes consumed by the Conversation API: | Scope | Endpoints | Tier policy | |---|---|---| | `tool.*` (baseline) | All session and message paths | All tiers | | `task.*` (baseline) | Pending-task / agent-bus paths | All tiers | | `pending.read` | `GET /pending`, `GET /sessions/{id}/pending` | All tiers (rows filtered by ownership at handler) | | `search.read` | `GET /search` | All tiers | | `tool_events.read` | `GET /sessions/{id}/tool-events` | All tiers (session ownership at handler) | | `uploads.write` | `POST /uploads`, `DELETE /uploads/{id}` | All tiers | | `uploads.read` | `GET /uploads/{id}`, `GET /uploads` | All tiers | | `character.write` | `POST /characters`, `DELETE /characters/{id}` | All tiers | | `character.read` | `GET /characters/{id}/state`, `GET /models/available-for-characters` | All tiers | | `admin.keys.write` | `POST /admin/keys`, `POST /admin/keys/{id}/rotate` | Admin only | | `admin.keys.read` | `GET /admin/keys` | Admin only | | `admin.keys.revoke` | `DELETE /admin/keys/{id}` | Admin only | | `admin.events.read` | `GET /admin/events` | Admin only | Per-user **ownership** (e.g., a user's own sessions) is enforced at the handler layer, not the Heimdall scope. Cross-user access by a non-admin returns 404 (never 403) per `INV-009` — Worldtree never leaks the existence of another user's resource. ### 2.4 Rate limiting Two buckets per user (post-`#117`): - **Per-user request rate** — pre-charged on the request boundary (before LLM call). 429 with `Retry-After` header on cap. - **Per-user token rate** — post-charged after the `done` event lands. A single bad turn can over-spend by orders of magnitude; recovery is natural refill. Operators wanting hard ceilings should pair with ADR-013 cost-cap enforcement at the gateway. Anonymous-tier requests share the `conv:req:anonymous` / `conv:tok:anonymous` buckets (separate from authenticated buckets). **Rate-limit-exempt endpoints** (`INV-022` structural exemption): - `GET /healthz`, `GET /readyz`, `GET /me` - `GET /admin/events` (SSE stream — connection itself is unmetered) - All `POST /admin/keys/*` paths - `GET /pending`, `GET /sessions/{id}/pending` (`INV-068` — a polling pending UI must not burn the user's request budget) Rate-limited endpoints return: ```json { "detail": { "error_code": "rate_limited", "message": "Rate limit exceeded for per_user_req_per_min", "scope": "per_user_req_per_min", "retry_after_s": 5.0 } } ``` plus `Retry-After: 5` header. --- ## 3. Health ### 3.1 `GET /healthz` → 200 Process liveness. Returns 200 as long as the FastAPI app is up. ### 3.2 `GET /readyz` → 200 / 503 Dependency readiness. Returns 503 with `error_code: "not_ready"` until startup completes (store opened, agents registered, Heimdall initialised). Flips to 503 again at the START of `shutdown()` so k8s drains traffic before the store closes (`INV-readyz-drain`, `#112`). --- ## 4. Self / `GET /me` Returns authenticated user info + key metadata. Rate-limit-exempt; safe to call at boot. ```json { "user_id": "alice", "tier": "user", "key_id": "kid_abc12345", "label": "production-2026" } ``` In dev/anonymous mode: `{"user_id": null, "tier": "anonymous"}`. --- ## 5. Sessions ### 5.1 `POST /sessions` → 201 Create a new conversation session. **Body** (additive fields per `#118`/`#123`/`#153`): ```jsonc { "agent_id": "mimir", // required; must be registered "record_tool_intermediates": false, // optional, #123 "character_id": null // optional, #153 — bind a transient character } ``` **Response** (`SessionInfo`): ```jsonc { "session_id": "sess_abc123…", "agent_id": "mimir", "user_id": "alice", // omitted in anonymous mode "message_count": 0, "created_at": "2026-05-07T03:00:00+00:00", "last_active": "2026-05-07T03:00:00+00:00", "metadata": {}, "name": null, "archived": false, "tags": [], "character_id": null // populated when bound } ``` **Errors:** - `404 agent_not_available` — unknown `agent_id` - `404 character_not_found` — unknown / cross-user `character_id` (`INV-009`) ### 5.2 `GET /sessions` → 200 Paginated session list, scoped to the authenticated user. Cursor-based per `#121`. **Query params:** - `include_archived: bool = false` — opt-in archived sessions - `limit: int = 50` (max 200) - `cursor: str | null` — opaque pagination token from a prior response **Response:** ```jsonc { "items": [{ "session_id": "sess_…", ... }], "next_cursor": "eyJ2I…" // null on last page } ``` **Errors:** - `422 cursor_invalid` — malformed or stale cursor - `422 validation_failed` — bad `limit` Cursor encodes `(created_at, session_id)`. `?include_archived` is filter state; cursor ignores it (filter changes mid-iteration are well-defined). ### 5.3 `GET /sessions/{session_id}` → 200 Returns one `SessionInfo`. 404 on cross-user / unknown. ### 5.4 `PATCH /sessions/{session_id}` → 200 (`#120`) Mutate session metadata. Body is partial; omitted fields are unchanged. ```jsonc { "name": "Auth shape discussion", // null clears "archived": true, "tags": ["auth", "infra"], "metadata": { "priority": "high" } // shallow-merged into existing metadata } ``` Returns the updated `SessionInfo`. 404 cross-user / unknown. ### 5.5 `DELETE /sessions/{session_id}` → 204 Hard delete. Cascades messages, turns, tool-events. Idempotent — DELETE on already-deleted returns 204. ### 5.6 `GET /sessions/{session_id}/messages` → 200 (`#121`) Paginated message history. **Query params:** - `limit: int = 50` (max 200) - `cursor: str | null` **Response:** ```jsonc { "session_id": "sess_…", "items": [{ "role": "user" | "assistant", "content": "...", "seq": 0 }], "next_cursor": "eyJ2…" } ``` `seq` is monotonic per session; `next_cursor` encodes the last seen `seq`. Tool-call intermediates are NOT included here (they live on the SSE stream and optionally in `/tool-events` per `#123`). ### 5.7 `POST /sessions/{session_id}/history` → 201 / 200 (`#347` authored history writes) Write one model-visible assistant turn into a session's ledger WITHOUT running a generation — for seeding openers, migrating history, or scripting a character's first line. **Grant-gated:** requires the `session.history.write` grant; without it the endpoint is invisible (see below). **Body** (`extra=forbid` — unknown keys are rejected): ```jsonc { "author": "assistant", // REQUIRED; v1: only "assistant" "content": "…", // REQUIRED, non-empty; bounded on UTF-8 BYTE length // (authored_content_max_bytes, default 8192) → 422 content_too_long "idempotency_key": "seed-001", // REQUIRED, non-empty — a BODY field, not a header "effects": null, // optional; omitted/null == "none"; v1: only "none" "claimed_original_at": null // optional, audit-only — never echoed on any read surface } ``` **The hide-404 (`INV-347-13`/`INV-347-1`):** the `session.history.write` grant is checked BEFORE any body parse or validation. Grant-denied, session-missing, and non-owner responses are byte-identical `404 session_not_found` — an ungranted caller can never learn the feature exists. All other errors are granted-only surfaces: - `422 malformed_request` — body is not valid JSON - `422 validation_failed` — extra key, bad `author`/`effects`, empty `content`/`idempotency_key` - `422 content_too_long` — over the byte bound - `409 generation_active` — session has an in-flight generation; retry after it settles - `401 auth_revoked` / `410 session_retired` — same lifecycle gates as the turn path **Idempotency:** `(session_id, idempotency_key)` is UNIQUE. A fresh write returns 201; a replay with the same key returns the ORIGINAL ack with 200 and writes nothing (ack fields derive from the persisted row, not the replay body). **Response (`AuthoredTurnResponse`):** ```jsonc { "turn_id": 18, "session_id": "sess_…", "author": "assistant", "phase": "seeded", "seq": 3, "injected_at": "2026-07-23T03:00:00+00:00", "content_chars": 42 // CHARACTER count — may differ from the byte bound above } ``` The write emits **no SSE, affect, or memory side effects** and persists a NORMAL `messages` row indistinguishable from a lived turn on all read surfaces (`INV-347-8`) — clients render it from `GET /sessions/{id}/messages` like any other assistant message. --- ## 6. Messages — SSE streaming ### 6.1 `POST /sessions/{session_id}/messages` → SSE Send a message; the agent's response streams back as Server-Sent Events. **Body:** ```jsonc { "content": "Tell me about CRDTs.", "upload_ids": ["upl_abc…"] // optional; per #118 } ``` **Headers (optional):** - `Last-Event-ID: :` — resume an in-flight turn (`#113`). **Response:** `Content-Type: text/event-stream`. Event id format is composite `:` per `INV-014`. ### 6.2 SSE event vocabulary Each event is a JSON object in the SSE `data:` field. The `type` field discriminates. #### `thinking` Incremental reasoning content (only from thinking-enabled models). ```json { "type": "thinking", "content": "Let me consider…" } ``` #### `text` Incremental assistant output. Concatenate all `text.content` per turn to reconstruct the full response. ```json { "type": "text", "content": "CRDTs are " } ``` #### `text_boundary` (NEW v2 — voice harness `#129`) A sentence/clause/forced boundary in the text stream. Emitted between consecutive `text` events at speakable breakpoints. Backwards-compatible: clients ignoring this event see today's per-token text behaviour bit-identically. ```json { "type": "text_boundary", "kind": "sentence" | "clause" | "forced", "char_offset": 42, "ts": "2026-05-07T03:00:00.123Z" } ``` - `kind: "sentence"` — sentence-end punctuation `.!?` followed by whitespace + capital-letter-or-eos. Mid-word punctuation (`Mr. Smith`) doesn't trigger. - `kind: "clause"` — clause punctuation `,;:—` past 40 tokens since last boundary. Also used for paragraph breaks (`\n\n`). - `kind: "forced"` — hard cap after 200 tokens of unpunctuated text. - `char_offset` — index into the cumulative text stream for this turn (sum of all preceding `text.content` lengths). `text_boundary` is **always** emitted regardless of agent voice config. Gateways without TTS just skip it. Code-fences (```` ``` ````) are hard boundaries (one at fence-open, one at fence-close); inside a fence no clause-fallback fires. Markdown markers (`**bold**`, `*italic*`) pass through to the boundary detector unchanged — gateway is responsible for stripping markdown before TTS synthesis. #### `tool_start` Agent is invoking a tool. ```json { "type": "tool_start", "name": "search_kb", "arguments": { "q": "CRDT" } } ``` **No `narrate` field** (`INV-102`). Tool-call narration is implicit: when the agent wants narration, it emits a `text` event with the narration BEFORE the `tool_start`. When silent, no text event precedes. The presence-or-absence of a preceding `text` event IS the suppression signal. #### `tool_result` Tool returned (success or failure). ```json { "type": "tool_result", "name": "search_kb", "result": { "hits": 3, "items": [...] }, "duration_ms": 142.3 } ``` #### `worker_phase` (NEW v2.1) Worker phase transition — emitted on ENTRY to each processing phase. ```json { "type": "worker_phase", "phase": "BuildingPrompt", "turn_id": 42 } ``` `phase` is a closed five-value set: `BuildingPrompt` (persona/tools/prompt assembly) → `CallingLLM` → `Streaming` (text deltas arriving) → `Finishing` (post-turn appraisal/audit before the terminal event); tool-using turns cycle `CallingLLM → ProcessingTools → CallingLLM → …` once per round-trip. Cancel/error paths skip `Finishing`. Clients that switch on `event["type"]` ignore this event without code changes. #### `awaiting_llm_first_token` (NEW v2.1 — `#201`) Periodic heartbeat during the `BuildingPrompt → CallingLLM` gap so a client can render a "thinking for Ns…" timer instead of a frozen line during slow first-token warmup. ```json { "type": "awaiting_llm_first_token", "turn_id": 42, "elapsed_ms_since_building_prompt": 5012.3 } ``` `elapsed_ms_since_building_prompt` is server-authoritative wall-clock (immune to network latency / clock skew). Heartbeats stop at the first engine event, fire only during the FIRST `BuildingPrompt → CallingLLM` gap (not tool-roundtrip re-entries), and never appear after a terminal `cancelled`. Interval: `conversation_api.awaiting_llm_first_token_heartbeat_s` (default 5.0; `0.0` disables; per-agent override available). #### `affect_update` (NEW v2.1 — `#204`) Persona-state observability. Fires twice per turn for persona-enabled agents on non-ephemeral sessions; suppressed entirely for persona-disabled agents, Tier 3 consumer agents, and ephemeral sessions. Start-of-turn (`status: "current"`, before any `worker_phase`) — carries the current persona snapshot reflecting all prior turns' completed appraisals: ```json { "type": "affect_update", "status": "current", "turn_id": 42, "snapshot": { "agent_id": "mimir", "pad": { "pleasure": 0.52, "arousal": 0.47, "dominance": 0.50 }, "dominant_emotion": "curiosity", "emotions_active": [{ "type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7 }], "baseline_pad": { "pleasure": 0.50, "arousal": 0.40, "dominance": 0.50 }, "mood_drift": { "valence_delta": 0.02, "arousal_delta": 0.07 }, "last_updated_at": "2026-07-23T03:00:00+00:00" } } ``` End-of-turn (`status: "scheduled"`, before `done`) — lightweight notification that the post-turn appraisal was scheduled (fire-and-forget; the result lands in the NEXT turn's `current` snapshot): ```json { "type": "affect_update", "status": "scheduled", "turn_id": 42 } ``` `scheduled` is skipped on failure/cancel paths; `current` still fires unconditionally for qualifying turns. Bootstrap reads: `GET /agents/{agent_id}/persona_state` (same `snapshot` shape, `persona.read` scope). #### `done` Turn completed normally. Last event before stream close. (Shape revised post-2.0: token counts moved into a `usage` object, `phase`/`response` added, `cost_usd` dropped.) ```json { "type": "done", "turn_id": 17, "phase": "succeeded", "response": "CRDTs are …", "model": "thoughtful-assistant", "duration_ms": 1842.3, "usage": { "prompt_tokens": 312, "completion_tokens": 64, "total_tokens": 376, "cached_input_tokens": 256 } } ``` - `response` — the complete assistant text (same as concatenated `text` events). - `model` — role-masked (`INV-344-3`, §11a.1): a role-defined agent surfaces its ROLE here, never the engine catalog id; only legacy raw-model configs surface a model string. - `usage` — all four fields always present; all `0` when the provider doesn't report counts. `total_tokens = prompt + completion`; `cached_input_tokens` is a subset of `prompt_tokens` (typically priced ~10% of full — gateway cost calculations use these; there is no server-side `cost_usd`). #### `error` Mid-stream failure. Last event before stream close. `error_code` is a typed bucket from the `#156` LLM-error classifier; `phase` is always `"failed"`. (`turn_id` rides in the composite SSE `id:` field, not the body.) ```json { "type": "error", "error_code": "internal_error", "message": "Provider timeout", "phase": "failed" } ``` #### `cancelled` Turn was cancelled. Last event before stream close (`#111`). ```json { "type": "cancelled", "turn_id": 17, "phase": "cancelled" | "stalled", "reason": "user_cancel" | "stall", "partial_message_id": null // populated if persist_partial=true was set } ``` ### 6.3 SSE resume (`#113`) If the gateway loses the connection mid-turn, reconnect with `Last-Event-ID: :` to replay buffered events from `seq+1` and resume live drain. The replay buffer is in-memory per turn; if it has been evicted, the response is `412 buffer_expired` with the original `turn_id` in the detail. Recovery: do not resume; query `/sessions/{id}/messages` for the canonical assistant message and proceed. ### 6.4 Cancellation (`#111`) `POST /sessions/{session_id}/turns/{turn_id}/cancel?persist_partial=false` → 200 `persist_partial` is a **query parameter** (default `false`); if true, partial assistant text is saved as a message with `partial: true`. **Response:** ```jsonc { "turn_id": 17, "cancelled": true, "reason": "user_cancel", "partial_message_id": null } ``` Idempotent. Errors: - `404 session_not_found` — unknown / cross-user - `404 turn_not_found` — unknown turn for this session - `409 turn_finished` — turn already completed before the cancel arrived Per `INV-009`-`014`: ≤100ms p99 cancel latency target; never leak existence to non-owners. ### 6.5 Stall watchdog (`INV-035..037`) Each in-flight turn carries a stall timer (default 300s). If no engine event arrives within the timeout, the turn auto-cancels with `reason: "stall"` (rather than `user_cancel`). Configurable per-agent via `conversation.stall_timeout_s` in agent config; per-deployment via `conversation_api.stall_timeout_s` in `defaults.yaml`. Partial output is unconditionally dropped on stall (no `persist_partial=true` opt-in). Stall is not an error per se — the turn ends with a `cancelled` SSE event, not `error`. --- ## 7. Uploads (`#118`) ### 7.1 `POST /uploads` → 200 Upload a file. `multipart/form-data` with one `file` field. **Headers:** `Authorization`, `Content-Type: multipart/form-data; boundary=…`. **Response:** ```jsonc { "upload_id": "upl_abc123…", // 32-hex "mime_type": "image/png", "size": 123456, "sha256": "...", "expires_at": "2026-05-08T03:00:00+00:00", // 24h default TTL "user_id": "alice" } ``` **Errors:** - `403 auth_scope_denied` — missing `uploads.write` - `413 upload_too_large` — exceeds `max_upload_bytes` (default 25 MB) - `413 quota_exceeded` — per-user count or total-bytes cap exceeded - `415 mime_type_disallowed` — MIME not in `mime_allowlist` - `429 rate_limited` Default `mime_allowlist`: `image/{png,jpeg,gif,webp}`, `application/pdf`, `text/{plain,markdown,csv}`. Per-deployment configurable in `conversation_api.uploads.mime_allowlist`. ### 7.2 `GET /uploads/{upload_id}` → 200 Returns metadata only — never content bytes. ```jsonc { "upload_id": "upl_…", "mime_type": "image/png", ... } ``` **Errors:** - `403 auth_scope_denied` — missing `uploads.read` - `404 upload_not_found` — unknown / cross-user / deleted - `410 upload_expired` — past `expires_at` (with `expires_at` in detail) ### 7.3 `DELETE /uploads/{upload_id}` → 200 Soft-delete (idempotent). ```jsonc { "upload_id": "upl_…", "deleted": true, "deleted_at": "2026-05-07T03:00:00+00:00" } // OR for second-call idempotent no-op: { "upload_id": "upl_…", "deleted": false, "reason": "already_deleted", "deleted_at": "..." } ``` ### 7.4 `GET /uploads` → 200 Paginated upload list, scoped to the user. ```jsonc { "items": [{ "upload_id": ... }], "next_cursor": "eyJ…" } ``` ### 7.5 Using uploads in messages ```jsonc POST /sessions/{id}/messages { "content": "Describe this", "upload_ids": ["upl_abc…"] } ``` Default cap 10 uploads per message. Worldtree validates upload existence + ownership + non-expiry before dispatch. Agents that don't accept uploads (`accepts_uploads: false` in their config) reject with `400 agent_lacks_upload_support`. --- ## 8. Cross-session search (`#122`) ### 8.1 `GET /search` → 200 Full-text search across the user's messages. **Query params:** - `q: str` (required, 1-200 chars) — FTS5 syntax (plain words, phrase via `"foo bar"`, boolean `foo AND bar`, prefix `auth*`) - `limit: int = 50` (max 200) - `cursor: str | null` - `session_id: str | null` — restrict to one session - `after: str | null` (ISO 8601 Z) — date floor - `before: str | null` — date ceiling - `role: "user" | "assistant" | null` - `include_archived: bool = true` — DEFAULT TRUE (search-consistency reasoning per `INV-077`; differs from `/sessions` where archived defaults FALSE) **Response (`SearchHit` × N):** ```jsonc { "items": [ { "session_id": "sess_…", "message_id": 42, "agent_id": "mimir", "role": "assistant", "content": "...", "snippet": "…highlighted term…", "score": -2.3, // BM25 — lower = better "created_at": "2026-05-07T03:00:00Z" } ], "next_cursor": "eyJ…" } ``` **Errors:** - `403 auth_scope_denied` — missing `search.read` - `422 search_query_invalid` — FTS5 syntax error (with the parser error in `detail.message`) - `422 cursor_invalid` / `422 validation_failed` Snippet format: `highlighted` markers, `…` truncation, 16-token context. Strip the markers gateway-side if you don't want HTML in the rendered output. Backend is SQLite FTS5 in v0; v1.x may swap to Postgres FTS or external (Tantivy/Meilisearch). The wire shape is backend-agnostic; client code must NOT depend on FTS5-specific behaviour beyond the documented syntax. --- ## 9. Tool-call persistence (`#123`) By default tool calls live only on the live SSE stream. Sessions opting in via `record_tool_intermediates: true` get queryable history of every tool call — **metadata only**, no arguments, no results (`INV-082` PII discipline). ### 9.1 Opting in ```jsonc POST /sessions { "agent_id": "mimir", "record_tool_intermediates": true } ``` The flag is **sticky** for the session's lifetime (`INV-081`); cannot be flipped via PATCH. ### 9.2 `GET /sessions/{session_id}/tool-events` → 200 ```jsonc { "session_id": "sess_…", "items": [ { "tool_call_id": "tc_abc123", "session_id": "sess_…", "turn_id": 17, "tool_name": "search_kb", "started_at": "2026-05-07T03:00:00Z", "ended_at": "2026-05-07T03:00:00.042Z", // null for cancelled-mid-flight "status": "completed" | "failed" | "cancelled" | "started", "error_type": null, "error_msg": null, "duration_ms": 42.3 } ], "next_cursor": "eyJ…" } ``` **Auth:** `tool_events.read`. Per-session ownership at handler. **Cancelled-mid-flight semantics** (`INV-086`): when a turn is cancelled while a tool call is in flight, the row gets `status='cancelled'` and `ended_at=NULL`. `duration_ms` carries elapsed wall time from start to cancel. What is NOT stored (`INV-082`): tool arguments, tool results, cleartext keys, persona affect state, LLM tokens. Capture those at the gateway from the live SSE stream if you need them. Retention: v0 has none — `tool_events` rows live forever until manual cleanup. Configurable retention deferred to `#133`. --- ## 10. Pending tasks (`#119`) Two pull-only views over in-flight agent-bus (Bus-v2) work invisible to the per-turn SSE stream. **Routes renamed post-2.0** — the former `GET /pending-tasks` is now `GET /pending`, plus a per-session variant. Both require `pending.read` and are rate-limit exempt (`INV-068`, §2.4). ### 10.1 `GET /pending` → 200 Cross-session view of the authenticated user's active bus tasks (e.g. background jobs, async tool invocations). Scoped at the bus-filter layer to the caller's user id; tasks with no originator user are excluded. ### 10.2 `GET /sessions/{session_id}/pending` → 200 Per-session view. 404 on unknown / cross-user session (`INV-009`). Tasks submitted without session context never appear here (they appear only in `GET /pending` for the submitting user). **Query params (both endpoints):** - `limit: int = 50` (1-200) - `cursor: str | null` **Response (both endpoints; `PendingTask` envelope per `INV-070` — all 8 fields always present):** ```jsonc { "items": [ { "task_id": "3f2a…", // UUID hex handle "kind": "bus_call", // only emitted value in v0; unknown kinds must be tolerated (INV-071) "target_agent_id": "muninn", // agent receiving the bus call "started_at": "2026-07-23T03:00:00Z", "eta_seconds": null, // always null in v0 "status": "pending", // always "pending" — only active tasks appear "session_id": "sess_… | null", // null for programmatic/scheduled submits "turn_id": "17 | null" } ], "next_cursor": "eyJ…" } ``` Reserved future `kind` values (`INV-071`, additive): `huginn_job`, `muninn_ingestion`, `scheduled_task` — treat unknown kinds as opaque, never as errors. The pending list reflects **in-memory** bus state and resets on process restart (`INV-067`) — gateway pending UIs MUST treat empty-after-restart as a normal path. --- ## 11. Transient characters (`#153`) Public primitive for downstream consumers (Skaldsong + RPG/game engines) needing dozens of distinct OCEAN-driven personas per session. Worldtree owns no durable state for these characters; consumer ships character JSON in, gets a `character_id`, runs sessions against it. In-memory only; process restart drops everything. The character displaces the **persona + model** layer of the bound session. The `agent_id` (typically `mask`) still resolves system prompt, tools, and the LLM provider unless the character carries a `role` override (role-keyed since the `#344` model-role cutover — see §11a.1). > **Full Skaldsong-side spec** lives in `~/development/skaldsong/docs/specs/worldtree-transient-characters-v1.md` (also pinned to Worldtree main `9004ce0`+). This section is the SEA-relevant condensation. ### 11.1 `POST /characters` → 201 ```jsonc { "character": { "schema_version": "1", // required, exactly "1" "name": "Hamlet", "ocean": { "openness": 0.5, // [-1.0, 1.0] signed; 0 = population mean "conscientiousness": 0.6, "extraversion": -0.3, "agreeableness": 0.2, "neuroticism": 0.6 }, "description": "...", // optional, ≤1000 chars "narrative": "...", // optional, free-form prose "voice_profile_block": "...", // optional, ≤300 chars, alphanumeric + standard punctuation "mood": {...}, // optional, mirrors agents//config.yaml persona.mood "emotions": {...}, // optional "goals": [...], // optional, motivational layer "fears": [...], // optional "role": "character", // optional model role (ADR-0012, §11a.1); omitted = host agent's provider "ttl_seconds": 14400 // optional, [60, 86400]; null = use default 14400 (4h) }, "state": null // optional CharacterStateSchema for rehydration } ``` **OCEAN range is `[-1.0, 1.0]`** signed (NOT `[0.0, 1.0]`). See [Worldtree's `docs/ocean-traits.md`](https://gitea.phasefinal.com/vh/Worldtree/src/branch/main/docs/ocean-traits.md) for the SOTA-grounded reference + 5-band behavioural mapping. **Response:** ```jsonc { "character_id": "char_<32hex>", "ttl_expires_at": "2026-05-07T07:00:00Z" } ``` **Auth:** `character.write`. Errors: `422 ttl_too_large`, `422 state_schema_outdated`, `404 model_not_available` (unknown `role` — the former `model_not_available_for_characters` code left with the `#344` role cutover), `422 validation_failed` (including a legacy `model` key — the field no longer exists), `429 quota_exceeded`. ### 11.2 `GET /characters/{character_id}/state` → 200 ```jsonc { "schema_version": "1", "pad": [0.4, 0.1, -0.2], // pleasure/arousal/dominance "emotions_active": [{"type": "joy", "intensity": 0.5, "fired_at": "...", "decayed": false}], "mood_drift": null, // optional, unbounded list "goal_signal_history": null } ``` **Auth:** `character.read`. Refreshes the character's TTL (sliding). ### 11.3 `DELETE /characters/{character_id}` → 200 Removes the character; binding sessions detach (next turn → 410 `character_not_found`). Does NOT cascade-delete the sessions. ### 11.4 `GET /models/available-for-characters` → 200 Returns the engine catalog ids currently bound to the `character` model role (informational — `CharacterSchema` selects by `role`, not by these ids). ```jsonc { "items": [{ "name": "char-rp", "description": "", "thinking": false }] } ``` **Auth:** `character.read`. The former per-model `available_for_characters: bool` allowlist on `config/providers.yaml` is superseded by the `character` role's catalog binds in `config/model_roles.yaml` (deployment config); `description`/`thinking` are vestigial (always `""` / `false`). ### 11.5 Binding via `POST /sessions` ```jsonc { "agent_id": "mask", "character_id": "char_…" } ``` The session's persona + model layer is displaced for every turn within. The `mask` agent (Worldtree-shipped; renamed from `actor` at v0.29.9, `#211`) is the canonical neutral host; use it for any character session unless you specifically need another agent's tool set. ### 11.6 Lifecycle | Behaviour | Default | Configurable? | |---|---|---| | TTL (stale → swept) | 4h | per-create via `ttl_seconds`; max 24h | | TTL refresh trigger | every binding-session turn + every `GET /state` | no | | Sweep cadence | every 60s | operator-side | | Sweep refusal | when any binding session has an in-flight turn | no | | Per-user cap | 100 characters | operator-side | | Persistence | none (in-memory only) | no | | DELETE → bound sessions | detach (next turn 410) | no | --- ## 11a. Tier 3 consumer-defined agents (`#181`/`#188`/`#344`) Consumer-owned, Worldtree-hosted persistent agents whose identity lives at `:` (session-create routes through the Tier 3 table whenever `agent_id` contains a `:`). Created via `POST /agents/define` (requires the `agents.define` scope + an authenticated bearer key), hard-deleted via `DELETE /agents/:`. The full define surface (persona / memory / motivational layers, quota, key-revocation cascade) is documented in Worldtree's `docs/conversation-api-spec.md` § "Tier 3 — Consumer-defined agents"; this section covers the two post-2.0 pieces SEA consumes: the model-role axis and the PATCH surface. The agents response shape (`define` 201, PATCH 200): ```jsonc { "agent_id": "alice:wizard", "user_id": "alice", "agent_name": "wizard", "system_prompt": "…", "role": "thoughtful-assistant", // W-4 (spec 1.2): named `role` on BOTH sides — never the engine catalog id "created_at": "2026-07-23T03:00:00+00:00", "updated_at": "2026-07-23T03:00:00+00:00", "warnings": [] // advisory (#219); ALWAYS [] for role-defined agents } ``` The response `role` echoes the requested role, symmetric with the request (W-4 closed, spec 1.2). The resolved engine catalog id is privileged and never surfaces (`INV-344-3`); only a legacy pre-cutover row defined with a raw model surfaces that stored model string here. ### 11a.1 The model-role axis (ADR-0012, `#344`) Two unrelated things are both called "role" on this API — do not conflate them: 1. **Message roles** — `user` | `assistant` on conversation turns (`GET /sessions/{id}/messages` items, the `?role=` search filter). Unchanged. 2. **Model roles** — the `role` field on `agents.define`/PATCH and on echo's `config.role`: a **purpose-named model seat**, not a raw model id. Worldtree resolves role → engine catalog binding server-side at turn time via `config/model_roles.yaml`; consumers pick a purpose, never an engine. Model roles are **NOT a schema enum** — the vocabulary is deployment config, so an unknown role fails at resolution with `404 model_not_available` (not 422). A denied role (not granted to the caller) is `403 auth_scope_denied` with `suggested_roles` in the detail; a configured-but-unavailable role is `503 model_not_available`. Current consumer-facing role catalog (character family + general seats): | Role | Seat | |---|---| | `character` | Dedicated non-reasoning RP seat; RP samplers baked server-side | | `thoughtful-character` | Dedicated RP **reasoning** seat | | `character-rp` | Same RP reasoning seat as `thoughtful-character` (like-for-like alias; its canonical RP samplers are server-baked) | | `assistant` | General (non-character) seat | | `thoughtful-assistant` | General **reasoning** seat | | `echo` | Echo ephemeral-session default (`config.role` omitted → `"echo"`) | The catalog is **deployment config, not API surface** — bindings can be re-pointed operator-side without any API change, and the table above reflects the pinned sha's `config/model_roles.yaml`. Raw model selectors left the public surface at the cutover: `model` is no longer a request field anywhere on the agents surface (PATCH rejects it by name: `422 field_not_mutable`), a non-empty echo `config.model` → `422 model_not_allowed` (omitted `config.role` resolves to `default_role` `"echo"`), and responses never name the underlying engine. ### 11a.2 `PATCH /agents/:` → 200 (`#188`) Mutable surface: **`system_prompt` and/or `role` ONLY.** The payload model is `extra=forbid`, and payload-shape rejection runs BEFORE the DB lookup — an immutable-field PATCH against a missing agent still 422s, never 404s. Rejection code depends on WHY the field can't be set: | Field(s) | Code | Reason | |---|---|---| | `agent_name`, `user_id`, `agent_id` | `422 field_not_mutable` | Identity — fixed at creation | | `model` | `422 field_not_mutable` | Catalog ids left the surface at the `#344` role cutover — select via `role` | | `persona`, `motivational`, `memory` | `422 field_not_mutable` | Shipped immutable traits (`memory` rejected wholesale) | | `valence` | `422 layer_deferred` | Not a shipped layer yet (matches define-time) | Supplying the key at all is the trigger, even with a `null` value. PATCH re-enforces define-time validation: `system_prompt` non-empty + 32 KiB byte cap (`422 system_prompt_too_large`); `role` must name a configured model role (`404 model_not_available` per §11a.1). A role change updates the role AND its derived catalog binding in one write (`INV-344-6`) — the row never carries a new role with a stale binding. **Response:** the agents response shape above + `warnings[]` — always `[]` for role-defined agents (`INV-344-3`; the `#219` advisory codes name catalog-level engine details and can only surface on legacy null-role rows). **Session pickup semantics:** - Active sessions keep their cached agent definition; a PATCH takes effect at the next session-create (`INV-181-17`). - A session resumed after a server restart rehydrates from the CURRENT definition — a PATCH landed between session-create and the restart IS picked up on resume (`INV-356-5`; the cache-immutability of `INV-181-17` is scoped to the cache's lifetime, which the restart ends). --- ## 12. Voice harness (`#129`/`#130`/`#131`) Three SSE/system-prompt extensions that let voice-tier consumers (SEA gateway, future Matrix voice rooms / native voice clients) run low-latency two-voice TTS pipelines. ### 12.1 `text_boundary` SSE event Already documented above (§6.2). Always emitted at speakable breakpoints regardless of agent voice config. ### 12.2 Implicit tool-call narration (`INV-102`) Documented above (§6.2 `tool_start`). No `narrate` field on `tool_start`; agents emit a `text` event before `tool_start` when narration is desired, emit nothing when silent. Worldtree owns what's spoken (SEA's C-4); gateway is a dumb pass-through. ### 12.3 Voice-classifier markers (per-agent config, `INV-103`/`INV-104`) When an agent's config has `voice.classifier_markers: true` in `agents//config.yaml`, the registry auto-appends a canonical instruction block to the agent's system prompt. The block tells the LLM to wrap output in `filler` and `substantive` tags inline in `text` event content. **Marker syntax (verbatim):** - `` ... `` — filler / structural / acknowledgement (Kokoro voice in SEA's two-voice multiplex) - `` ... `` — substantive / emotive (Fish voice in SEA's two-voice multiplex) Markers appear inline in the `text` event content as authored by the LLM; gateway parses client-side. **Worldtree adds NO wire surface for parsing.** Plain `text` events; the marker tags ride inside the content string. Static for the agent's lifetime (`INV-103`) — cannot be flipped mid-session. Operators flip the flag per-deployment. ### 12.4 SEA's two-voice TTS multiplex (informative) Per ADR-007 + R-23: SEA routes `...` text to Kokoro (instant filler) and `...` text to Fish (substantive emotive). The `text_boundary` events from §12.1 give SEA the chunk boundaries; the marker tags from §12.3 give SEA the voice-routing classifier. --- ## 13. Admin keys ### 13.1 `POST /admin/keys` → 201 (`#115`) Issue a new API key. **The cleartext key is returned ONCE** and must be captured by the caller; subsequent reads return only metadata. ```jsonc { "user_id": "alice", "label": "production-2026" } ``` The handler auto-creates the user at `tier="user"` if `user_id` does not yet exist. Issuing keys at other tiers (e.g. `admin` for a gateway's bootstrap key) is not currently supported via this endpoint — it requires direct provisioning against the user_store. Per-issuance tier override is tracked as a follow-up. **Response (cleartext shown only here, INV-019):** ```jsonc { "key": "wt_live_<32hex>", // cleartext — capture now or it's lost "key_id": "kid_abc12345", "user_id": "alice", "label": "production-2026", "created_at": "..." } ``` **Auth:** `admin.keys.write`. ### 13.2 `GET /admin/keys` → 200 List keys (metadata only — no cleartext anywhere). ### 13.3 `DELETE /admin/keys/{key_id}` → 200 (`#116`) Revoke a key. Idempotent. ### 13.4 `POST /admin/keys/{key_id}/rotate` → 200 (`#116`) Issue a successor key with a grace window during which both keys auth. ```jsonc { "grace_seconds": 300 } // 0 to disable grace window; defaults from defaults.yaml ``` **Response:** new key's metadata + cleartext (one-shot) + `supersedes_until` ISO 8601 timestamp. `grace_seconds=0` does NOT force-disconnect in-flight streams holding the old key (`INV-no-force-disconnect`, `#116`). Operators wanting hard cut should pair with their own connection-draining logic. --- ## 14. Admin events (`#127`) ### 14.1 `GET /admin/events` → SSE Server-sent event stream broadcasting all conv-api lifecycle events to admin-tier consumers (gateway, audit tooling, ops dashboards). **Auth:** `admin.events.read`. Connection itself audited as `conversation_api:admin:events:connect`. **Per-event emission is NOT audited** (`INV-052`). **Headers (optional):** - `Last-Event-ID: ` — replay buffered events from this id+1. **Event envelope** (`INV-046`): ```jsonc { "id": 42, // monotonic int per process; resets on restart (INV-047) "type": "session.created", // dotted-namespace from documented vocabulary "timestamp": "2026-05-07T03:00:00.123Z", "data": { ... } // type-specific } ``` **v0 event vocabulary (16 types):** Session lifecycle: - `session.created` — `{session_id, agent_id, user_id, character_id?}` - `session.updated` — `{session_id, changed_fields: [...]}` - `session.archived` / `session.unarchived` — `{session_id}` - `session.deleted` — `{session_id}` Turn lifecycle: - `turn.started` — `{session_id, turn_id, agent_id, user_id}` - `turn.completed` — `{session_id, turn_id, duration_ms, phase: "succeeded"}` - `turn.failed` — `{session_id, turn_id, duration_ms, phase: "failed", error: "ExceptionType: msg≤200ch"}` - `turn.cancelled` — `{session_id, turn_id, duration_ms, phase: "cancelled", reason: "user_cancel"}` - `turn.stalled` — `{session_id, turn_id, duration_ms, phase: "stalled", reason: "stall"}` Admin keys: - `key.issued` — `{key_id, target_user_id, actor_user_id}` - `key.revoked` — same - `key.rotated` — `{old_key_id, new_key_id, target_user_id, actor_user_id}` System: - `system.startup` — `{version, git_sha, first_event_id: 1}` - `system.heartbeat` — `{}` (every 30s on idle connections) - `system.shutdown` — `{}` - `system.events_dropped` — `{count, gap_first_id, gap_last_id}` (per-consumer queue overflow) - `system.replay_gap` — `{requested_id, available_from_id}` (out-of-buffer reconnect) Transient characters (`#153`): - `character.created` — `{character_id, user_id}` - `character.deleted` — same - `character.expired` — same (sweep-fired) Tool-events recording (`#123`): - `session.tool_recording_enabled` — `{session_id, agent_id, user_id}` (fired at session create when flag is true) ### 14.2 PII discipline (`INV-049`) Admin events carry **IDs and small structured metadata only**. Never message content, tool arguments, tool results, cleartext keys, partial assistant output, persona affect state, or LLM tokens. The `error` string in `turn.failed` is `ExceptionType: msg[:200]` — full traceback stays in the logger, not the event. For `key.issued` / `key.rotated`: events carry `key_id` (8-hex identifier) but NEVER the cleartext key (which appears exactly once in the POST response per `INV-019`). For `character.created`: events carry `{character_id, user_id}` only — never name, description, narrative, OCEAN values, or voice_profile content (those are caller-defined and might encode sensitive material). ### 14.3 Buffer + backpressure In-memory ring buffer `maxlen=1000` events (`INV-050`). Out-of-buffer reconnect → `system.replay_gap` then live. Per-consumer `asyncio.Queue(maxsize=1000)` (`INV-051`). On overflow: oldest popped, `system.events_dropped` enqueued in its place with the gap range. Drop notifications are best-effort — a persistently-slow consumer can lose them. Process restart drops the buffer; `id` resets to 1 with a fresh `system.startup`. --- ## 15. Error codes (canonical reference) All error responses (REST 4xx/5xx + SSE error events) carry a stable `error_code` from this enum. Gateway dispatches on the code, not the message text (`INV-stable-error-codes`). | Code | HTTP | When | |---|---|---| | `auth_missing` | 401 | No `Authorization` header | | `auth_invalid` | 401 | Bearer token malformed / unknown | | `auth_user_disabled` | 401 | User account disabled | | `auth_key_disabled` | 401 | Key revoked | | `auth_key_superseded` | 401 | Old key past grace window | | `auth_revoked` | 401 | Session's owning key revoked (turn + authored-write paths) | | `auth_scope_denied` | 403 | Token lacks required scope | | `session_not_found` | 404 | Unknown / cross-user session (no leak) | | `agent_not_available` | 404 | Unknown `agent_id` at session create | | `turn_not_found` | 404 | Unknown turn for cancel | | `turn_finished` | 409 | Cancel arrived after turn completed | | `generation_active` | 409 | Authored write while a generation is in flight (§5.7) | | `session_retired` | 410 | Session administratively retired | | `key_not_found` | 404 | Unknown key id | | `key_revoked` | 410 | Key revoked | | `key_already_superseded` | 409 | Rotate target already superseded | | `last_event_id_invalid` | 400 | Malformed `Last-Event-ID` header | | `buffer_expired` | 412 | SSE replay buffer evicted | | `cursor_invalid` | 422 | Malformed pagination cursor | | `rate_limited` | 429 | Per-user rate cap hit (`Retry-After` header) | | `validation_failed` | 422 | Body / query param validation | | `content_too_long` | 422 | `content` over the limit | | `not_ready` | 503 | `/readyz` or any path before startup completes | | `method_not_allowed` | 405 | Wrong HTTP verb | | `malformed_request` | 400 | Bad JSON / shape | | `search_query_invalid` | 422 | FTS5 syntax error (with parser msg) | | `upload_too_large` | 413 | Single upload over `max_upload_bytes` | | `quota_exceeded` | 413 / 429 | Per-user count/bytes/character cap hit | | `mime_type_disallowed` | 415 | MIME not in allowlist | | `upload_expired` | 410 | Past `expires_at` | | `upload_not_found` | 404 | Unknown / cross-user upload | | `agent_lacks_upload_support` | 400 | Agent without `accepts_uploads: true` got `upload_ids[]` | | `character_not_found` | 404 / 410 | Unknown / cross-user / deleted character | | `ttl_too_large` | 422 | `ttl_seconds` over max | | `state_schema_outdated` | 422 | `schema_version` not in `accepted_versions` (detail includes accepted list) | | `model_not_available` | 404 / 503 | Unknown model role (404) or role temporarily unavailable (503) — §11a.1 | | `field_not_mutable` | 422 | Immutable field on `PATCH /agents/` (§11a.2) | | `layer_deferred` | 422 | Deferred layer (`valence`) supplied on define/PATCH | | `system_prompt_too_large` | 422 | Tier 3 `system_prompt` over the 32 KiB byte cap | | `internal_error` | 500 | Unexpected server failure | Detail shape: ```jsonc { "detail": { "error_code": "rate_limited", "message": "...", // optional context fields per code: "scope": "per_user_req_per_min", "retry_after_s": 5.0, "accepted_versions": ["1"], "expires_at": "...", ... } } ``` --- ## 16. SEA-specific notes ### 16.1 Mapping to SEA requirements | SEA req | Worldtree issue | Status | Surface | |---|---|---|---| | R-1 turn cancellation | #111 | ✓ | §6.4 | | R-2 health/ready | #112 | ✓ | §3 | | R-3 SSE resume | #113 | ✓ | §6.3 | | R-4 token usage in done | #117 | ✓ | §6.2 `done` | | R-5 per-user keys | #115 | ✓ | §13.1 | | R-6 key rotation | #116 | ✓ | §13.4 | | R-7 rate limiting | #117 | ✓ | §2.4 | | R-8 TTS chunking | #129 | ✓ | §6.2 `text_boundary`, §12.1 | | R-9 tool-call narration | #130 | ✓ | §6.2 `tool_start`, §12.2 | | R-10 file uploads | #118 | ✓ | §7 | | R-11 pending tasks | #119 | ✓ | §10 | | R-12 session metadata mutation | #120 | ✓ | §5.4 | | R-13 pagination | #121 | ✓ | §5.2, §5.6 | | R-14 cross-session search | #122 | ✓ | §8 | | R-15 tool-call persistence | #123 | ✓ | §9 | | R-16 GET /me | (incl) | ✓ | §4 | | R-17 structured error codes | (incl) | ✓ | §15 | | R-18 richer agent metadata | (partial) | ⏳ | — | | R-19 webhook / event bus | #127 | ✓ | §14 | | R-20a log redaction | #132 | ⏳ tier-3 | — | | R-20b audit retention | #133 | ⏳ tier-3 | — | | R-20c TLS 1.3 | #134 | ⏳ tier-3 | — | | R-20d FIPS crypto | #135 | ⏳ tier-3 | — | | R-21/22 horizontal scaling | #128 | ⏳ tier-3 | — | | R-23 voice-classifier markers | #131 | ✓ | §12.3 | **Net read:** every R-1..R-19 + R-23 item is shipped. The remaining gaps are R-20 compliance hardening and R-21/22 horizontal scaling — both tier-3, deferred until a customer's actual launch timeline forces them. ### 16.2 SEA's C-* commitments and how Worldtree honors them | Commitment | Worldtree behaviour | |---|---| | **C-1 Single-tenant** | Per ADR-008; one Worldtree instance per SEA tenant. Worldtree carries no multi-tenant logic; KB-leakage prevention out of upstream scope. | | **C-2 Text-only** | Worldtree never handles audio. STT/TTS lives gateway-side. `text_boundary` events are pure metadata over the text stream. | | **C-3 Privacy flows from text** | Worldtree controls text content; gateway TTS speaks only what Worldtree emits. Worldtree's text-output safety IS the voice safety. No gateway-side redaction layer needed. | | **C-4 Tool-call narration is Worldtree's call** | §6.2 `tool_start` + §12.2 implicit-narration mechanism. Gateway is a dumb TTS pass-through; never injects narration. | | **C-5 Per-tenant deployable** | One Worldtree per tenant; no logic for serving multiple from one backend. | --- ## 17. Versioning policy This spec is **v2.1**. v1.0 (`conversation-api-spec.md` in this directory) is frozen historical and should not be consumed by new code. Worldtree-side: `INV-046` pins the admin event envelope; `INV-093` pins schema_version on character schemas; the wire shape of REST endpoints is not strictly versioned but breaking changes require contract amendment + spec section update + this document version-bump. When this document and the Worldtree contract diverge, **the Worldtree contract wins** (`docs/contracts/conversation_api.contract.md` is the source of truth for invariants; `docs/conversation-api-spec.md` for endpoint shapes). File a SEA-tracker issue cross-referencing the Worldtree commit that introduced the divergence and the line of this document that's now stale. This document tracks the Worldtree main sha at `9004ce0..69d162d`. When Worldtree main moves and the spec's surface changes, refresh this document AND bump `version` + `spec_date`. --- ## 17a. About Bifrost auth (`about-Bifrost-auth`) Worldtree dispatches tool calls to Bifrost consumers (mead-hall, Skaldsong, SEA, durable-character demos, future consumers) over the `bifrost` MCP-in-reverse protocol. As of v0.3 (issue #180), every `POST /bifrost/tool-call` carries a per-dispatch JWT in the `Authorization: Bearer ` header — distinct from the longer-lived handshake JWT in `POST /bifrost/handshake`. This section documents the claim shape Worldtree commits to as the platform issuer. ### Wire surface Worldtree appends one header to every tool-call POST: ``` Authorization: Bearer ``` `` is a freshly-minted JSON Web Token (RFC 7519) signed with the same consumer-registered key bytes Worldtree uses for the handshake JWT (HS256 default; RS256 supported via the `algorithm` constructor kwarg). The handshake JWT is unchanged and still flows in the `auth.token` field of the handshake request body. ### Claim shape (RFC 7519 registered names + `scope`) | Claim | Type | Value | |---|---|---| | `sub` | string | The session_id (Worldtree's session UUID) | | `iss` | string | Literal `"worldtree"` | | `aud` | string | The consumer_id as registered with Worldtree | | `iat` | integer | Unix seconds, mint timestamp | | `exp` | integer | `iat + 60` | | `jti` | string | UUIDv4 | | `scope` | array of strings | Pass-through from the handshake-time scope; `[]` when scope is unset | The mint helper is bifrost's reference implementation at `bifrost.core.dispatch_jwt.mint_dispatch_jwt`. Worldtree imports it directly rather than vendoring to avoid drift between bifrost's canonical helper and a local copy. ### Locked-in Worldtree-side conventions Resolved on the althing thread `01KRWS91TFDSGHVS3PPWK44C5W` (mead-hall coordination) with cite-grounded answers Q1–Q5: - **Q1 — Signing key.** HS256 signs over the same bytes Worldtree uses for the handshake JWT (`heimdall_key` registered with the consumer). No new key-source registration; no operator-side change to consumer registrations. RS256 is symmetric and uses the same PEM keypair as the handshake path. - **Q2 — `aud` value.** Literal `consumer_id` as registered. No prefix, no namespace, no transformation. Consumer-side equality check is `aud == `. - **Q3 — `scope` shape.** `list[str]`, pass-through from the BifrostClient's `scope` kwarg. When the kwarg is `None` (the common case for non-scoped sessions), Worldtree emits `[]`. When set to a string `s`, Worldtree emits `[s]` — a single-element list wrapping the opaque consumer-defined string. If a use case shows up for splitting `scope` into multiple structured tokens, that's a future amendment to this section with consumer-side coordination. - **Q4 — `jti` shape.** UUIDv4, helper default. Globally unique by construction. Worldtree does not maintain a `jti` cache (replay defense is the consumer's responsibility per v0.3 spec § 4); the platform mint side is stateless. - **Q5 — `iss` value.** Literal `"worldtree"` for the foreseeable future. If multiple Worldtree instances ever need to disambiguate (per-tenant clusters signing to a shared consumer pool), that's a coordinated change announced through the same althing channels — not silent. ### TTL and mint cadence The token is short-lived: `exp - iat == 60` seconds, mint-per-HTTP-call. Worldtree's retry loop in `_invoke_with_retry` mints a fresh JWT on each `_send_tool_call` attempt, so a connection drop + retry produces two distinct `jti` values rather than reusing a stale token. Consumers MUST NOT cache the per-dispatch JWT across retries. ### Error codes Four new `bifrost.*` codes are returned by spec-conforming consumers on per-dispatch JWT failures. Worldtree's `BifrostClient` raises them as `BifrostError` (Layer-1) via the existing error-envelope dispatch path; the four codes route through unchanged: | Code | Status | Meaning | |---|---|---| | `bifrost.dispatch_auth_missing` | 401 | Authorization header absent on a tool-call POST | | `bifrost.dispatch_auth_invalid` | 401 | JWT signature does not verify against the registered key | | `bifrost.dispatch_auth_expired` | 401 | `exp` is in the past relative to the consumer's clock | | `bifrost.dispatch_auth_audience_mismatch` | 403 | `aud` does not match the consumer's registered identity | ### Interop with v0.2 v0.2 and v0.3 do not interoperate. A Worldtree on v0.3 will receive `bifrost.dispatch_auth_missing` from a v0.2 consumer that strictly validates the wire shape, since v0.2 consumers did not specify per-dispatch auth. The cutover is announced to consumers in advance; no compat shim exists on either side. --- ## 18. Cross-references - **Worldtree contract** (authoritative): [`docs/contracts/conversation_api.contract.md`](https://gitea.phasefinal.com/vh/Worldtree/src/branch/main/docs/contracts/conversation_api.contract.md) - **Worldtree spec** (endpoint reference): [`docs/conversation-api-spec.md`](https://gitea.phasefinal.com/vh/Worldtree/src/branch/main/docs/conversation-api-spec.md) - **Worldtree main sha at this spec's authoring:** `69d162d` - **Worldtree OCEAN reference:** [`docs/ocean-traits.md`](https://gitea.phasefinal.com/vh/Worldtree/src/branch/main/docs/ocean-traits.md) - **Skaldsong character spec:** `~/development/skaldsong/docs/specs/worldtree-transient-characters-v1.md` (for downstream consumer side of `#153`) - **SEA worldtree-requirements:** [`../worldtree-requirements.md`](../worldtree-requirements.md) - **SEA ADRs:** [`../DECISIONS.md`](../DECISIONS.md) --- ## 19. Change log for this spec | Date | Version | Change | |---|---|---| | 2026-04-15 | 1.0 | Initial. Pre-Heimdall, pre-extensions. (Frozen at `conversation-api-spec.md` in this directory.) | | 2026-05-07 | 2.0 | Full rewrite covering #110 umbrella through #153 + voice harness. Pinned to Worldtree main `ddaacc9`. v1 superseded but retained for historical reference. | | 2026-07-23 | 2.1 | Refresh to Worldtree main `69d162d`: pending rename (`GET /pending` + per-session variant, INV-070 envelope); three post-2.0 SSE events (`worker_phase`, `awaiting_llm_first_token`, `affect_update`); NEW §11a Tier 3 agents — ADR-0012 model-role axis (`#344`) + `PATCH /agents` (`#188`) with W-4 `role` response field; NEW §5.7 authored-history writes (`#347`); transient characters role-keyed (`model` → `role`); `actor` → `mask` (`#211`); cancel route path corrected to `/turns/` (+ `persist_partial` is a query param); terminal-event shapes corrected (`done` usage object + role-masked `model`, `phase` on `error`/`cancelled`); error-code table updated. | --- ## 20. Reporting issues If something in this spec is wrong, ambiguous, or doesn't match what Worldtree returns: 1. File a Worldtree issue (`python -m core.issues create` from the Worldtree repo, or via Gitea web UI) labelled `conversation-api` + `bug` (or `documentation` if doc-only). 2. Reference this document by filename and version in the issue body. 3. The Worldtree contract is authoritative — corrections may land in this spec, in the Worldtree contract, or both, depending on which side has the gap.