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.
59 KiB
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 |
|
|
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-Afterheader on cap. - Per-user token rate — post-charged after the
doneevent 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 /meGET /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— unknownagent_id404 character_not_found— unknown / cross-usercharacter_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 sessionslimit: 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 cursor422 validation_failed— badlimit
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 JSON422 validation_failed— extra key, badauthor/effects, emptycontent/idempotency_key422 content_too_long— over the byte bound409 generation_active— session has an in-flight generation; retry after it settles401 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 precedingtext.contentlengths).
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) → 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.
{ "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 concatenatedtextevents).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; all0when the provider doesn't report counts.total_tokens = prompt + completion;cached_input_tokensis a subset ofprompt_tokens(typically priced ~10% of full — gateway cost calculations use these; there is no server-sidecost_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-user404 turn_not_found— unknown turn for this session409 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— missinguploads.write413 upload_too_large— exceedsmax_upload_bytes(default 25 MB)413 quota_exceeded— per-user count or total-bytes cap exceeded415 mime_type_disallowed— MIME not inmime_allowlist429 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— missinguploads.read404 upload_not_found— unknown / cross-user / deleted410 upload_expired— pastexpires_at(withexpires_atin 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", booleanfoo AND bar, prefixauth*)limit: int = 50(max 200)cursor: str | nullsession_id: str | null— restrict to one sessionafter: str | null(ISO 8601 Z) — date floorbefore: str | null— date ceilingrole: "user" | "assistant" | nullinclude_archived: bool = true— DEFAULT TRUE (search-consistency reasoning perINV-077; differs from/sessionswhere 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— missingsearch.read422 search_query_invalid— FTS5 syntax error (with the parser error indetail.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 main9004ce0+). 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:
- Message roles —
user|assistanton conversation turns (GET /sessions/{id}/messagesitems, the?role=search filter). Unchanged. - Model roles — the
rolefield onagents.define/PATCH and on echo'sconfig.role: a purpose-named model seat, not a raw model id. Worldtree resolves role → engine catalog binding server-side at turn time viaconfig/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 ofINV-181-17is 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— samekey.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— samecharacter.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 Q1–Q5:
- Q1 — Signing key. HS256 signs over the same bytes Worldtree uses for the handshake JWT (
heimdall_keyregistered 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 —
audvalue. Literalconsumer_idas registered. No prefix, no namespace, no transformation. Consumer-side equality check isaud == <self-id>. - Q3 —
scopeshape.list[str], pass-through from the BifrostClient'sscopekwarg. When the kwarg isNone(the common case for non-scoped sessions), Worldtree emits[]. When set to a strings, Worldtree emits[s]— a single-element list wrapping the opaque consumer-defined string. If a use case shows up for splittingscopeinto multiple structured tokens, that's a future amendment to this section with consumer-side coordination. - Q4 —
jtishape. UUIDv4, helper default. Globally unique by construction. Worldtree does not maintain ajticache (replay defense is the consumer's responsibility per v0.3 spec § 4); the platform mint side is stateless. - Q5 —
issvalue. 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 - Worldtree spec (endpoint reference):
docs/conversation-api-spec.md - Worldtree main sha at this spec's authoring:
69d162d - Worldtree OCEAN reference:
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 - SEA ADRs:
../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:
- File a Worldtree issue (
python -m core.issues createfrom the Worldtree repo, or via Gitea web UI) labelledconversation-api+bug(ordocumentationif doc-only). - Reference this document by filename and version in the issue body.
- The Worldtree contract is authoritative — corrections may land in this spec, in the Worldtree contract, or both, depending on which side has the gap.