Files
ratatoskr/docs/conversation-api-client-spec-v2.md
T
vh 14bbc2b48e docs(canonical): add client-spec-v2 pin + sync worldtree conversation-api canonicals
Operator decision A (client-spec tracking): add a first-class pin for
docs/conversation-api-client-spec-v2.md (v2.1, sha 1ff1d73a — the
client-facing spec our #371 parity pass shaped), vendored as a consumer
copy, tolerate_drift=true (prose reference; OpenAPI+SSE stay the gates).

canonical_sync is whole-manifest (no per-pin scope), so it also brought
two independently-drifted pins current:
- OpenAPI hard-gate 36148179 (2.3.0) -> 1c801547. Additive-only within
  the FROZEN v1 wire (Worldtree #326) — no code/SDK breakage; the SDK
  targets 2.3.0 and still works against the newer additive surface.
- conversation-api-spec.md (full prose) -> f6e964ec (v1.3+).

canonical_drift now exits 0. FOLLOW-UP (non-urgent): the OpenAPI moved,
so a coverage-map re-audit against the new surface would surface any new
in-scope I/O points worth consuming (ratatoskr v1 = full WT I/O coverage).
Release-only cadence: no tag.
2026-08-01 16:58:39 -07:00

59 KiB
Raw Blame History

spec_name, version, spec_date, worldtree_main_sha, worldtree_module_contract, worldtree_spec, supersedes, status, covers, deferred_v2x
spec_name version spec_date worldtree_main_sha worldtree_module_contract worldtree_spec supersedes status covers deferred_v2x
worldtree-conversation-api 2.1 2026-07-23 69d162d docs/contracts/conversation_api.contract.md docs/conversation-api-spec.md worldtree-reference/conversation-api-spec.md (v1.0, 2026-04-15, pre-auth pre-extensions) current — pinned to Worldtree main 69d162d
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
#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 and 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 (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):

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:

{
  "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.

{
  "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):

{
  "agent_id": "mimir",                       // required; must be registered
  "record_tool_intermediates": false,        // optional, #123
  "character_id": null                       // optional, #153 — bind a transient character
}

Response (SessionInfo):

{
  "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:

{
  "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.

{
  "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:

{
  "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):

{
  "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):

{
  "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:

{
  "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).

{ "type": "thinking", "content": "Let me consider…" }

text

Incremental assistant output. Concatenate all text.content per turn to reconstruct the full response.

{ "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.

{
  "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.

{ "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).

{
  "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.

{ "type": "worker_phase", "phase": "BuildingPrompt", "turn_id": 42 }

phase is a closed five-value set: BuildingPrompt (persona/tools/prompt assembly) → CallingLLMStreaming (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.

{ "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:

{
  "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):

{ "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.)

{
  "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.)

{
  "type":       "error",
  "error_code": "internal_error",
  "message":    "Provider timeout",
  "phase":      "failed"
}

cancelled

Turn was cancelled. Last event before stream close (#111).

{
  "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:

{
  "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:

{
  "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.

{ "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).

{ "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.

{ "items": [{ "upload_id": ... }], "next_cursor": "eyJ…" }

7.5 Using uploads in messages

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):

{
  "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

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

{
  "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):

{
  "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

{
  "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 for the SOTA-grounded reference + 5-band behavioural mapping.

Response:

{
  "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

{
  "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).

{ "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

{ "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):

{
  "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 rolesuser | 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.model422 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.

{
  "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):

{
  "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.

{ "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):

{
  "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:

{
  "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 Q1Q5:

  • 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


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 (modelrole); actormask (#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.