---
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: <json>` 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_<env>_<32hex>`.

Production keys: `wt_live_<32hex>`. The `wt_test_` prefix is reserved but currently unused. Any other `wt_<env>_` 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: <turn_id>:<seq>` — resume an in-flight turn (`#113`).

**Response:** `Content-Type: text/event-stream`. Event id format is composite `<turn_id>:<seq>` 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: <turn_id>:<seq>` 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":     "…<mark>highlighted</mark> 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: `<mark>highlighted</mark>` 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/<n>/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 `<user_id>:<agent_name>` (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/<user_id>:<agent_name>`. 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/<user_id>:<agent_name>` → 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/<n>/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 `<system>filler</system>` and `<character>substantive</character>` tags inline in `text` event content.

**Marker syntax (verbatim):**
- `<system>` ... `</system>` — filler / structural / acknowledgement (Kokoro voice in SEA's two-voice multiplex)
- `<character>` ... `</character>` — 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 `<system>...</system>` text to Kokoro (instant filler) and `<character>...</character>` 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: <int>` — 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/<id>` (§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 <jwt>` 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 <jwt>
```

`<jwt>` 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 == <self-id>`.
- **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.
