mumo REST API
Run structured multi-model deliberations on demand. Send a prompt; get back a session containing every model's response and the cross-model claim map. Opt in to a per-round Takeaway via the takeaway parameter.
This is the consumer reference. For agent-runtime use (Claude Code, Cursor, etc.), see the MCP server docs.
Quickstart#
- Get a key at mumo.chat/settings/api-keys. Keys begin with
mmo_live_. - Send a prompt:
curl https://mumo.chat/api/deliberation \
-H "Authorization: Bearer mmo_live_…" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Should we use Postgres or MongoDB for an event store?",
"rounds": 1
}'
You'll get back a session object. With rounds: 1 the status is ready immediately and the round's full artifact stack is in the response. Here's a redacted example:
{
"id": "f5731fe5-27ce-4f82-b8ce-868e72ff8bb9",
"status": "ready",
"mode": "remote",
"active_models": ["claude-opus-4-6", "gpt-5.4", "grok-4-20-reasoning"],
"rounds": [
{
"index": 0,
"completion_state": "complete",
"responses": [
{ "model": "claude-opus-4-6", "text": "...", "snippets": [...] },
{ "model": "gpt-5.4", "text": "...", "snippets": [...] },
{ "model": "grok-4-20-reasoning", "text": "...", "snippets": [...] }
],
"claim_map": {
"claims": [
{
"quote": "Postgres' append-only WAL plus JSONB columns gives you...",
"originator": "claude-opus-4-6",
"reaction_count": 2,
"positions": [
{ "model": "gpt-5.4", "type": "KEEP", "comment": "Agreed — JSONB lets you..." },
{ "model": "grok-4-20-reasoning", "type": "CHALLENGE", "comment": "But what about field-level migrations 18 months in?" }
]
}
]
},
}
],
"summary": null,
"confidence_disclaimer": "Confidence scores (0–1) on claims and snippet comments are self-reported..."
}
Read the artifact stack section below for what each piece is for.
Authentication#
Authorization: Bearer mmo_live_…
Keys are minted at /settings/api-keys. Each key is hashed at rest; copy the secret on creation — it's never shown again.
If your client or gateway reserves the Authorization header (Smithery does), send the same key bare in X-Mumo-Api-Key instead:
X-Mumo-Api-Key: mmo_live_…
Same key, same lookup, same errors. Send one or the other. If both arrive (a gateway that injects its own Authorization on the upstream hop), a platform key in Authorization wins, otherwise the key in X-Mumo-Api-Key does; any other value in X-Mumo-Api-Key is ignored.
Calls without a valid key return 401 Unauthorized. Registered callers can select any model in the registry — runtime success is governed by credit balance, not tier. Anonymous callers see only tier-0 models in GET /api/models.
Credit wallet#
Every billable LLM call debits a dollar-denominated wallet. A write is admitted when the caller's effective balance is above zero and covers the requested round's minimum: each model's reserved output room plus the input, plus the post-round reactions and takeaway. Otherwise it is rejected pre-flight with 403 credit_exhausted — see Errors. At zero or below every write is refused; at a low positive balance a larger panel can be refused while a smaller one is admitted, and the refusal names the minimum and the rosters that fit. A round whose true cost overshoots the remaining balance still completes; the balance stops at $0.00 and the excess is never carried as debt. If the balance can't cover a panel round's reactions and takeaway, they are skipped (post_round_status: skipped_balance).
Three buckets, FIFO debit: free → subscription → refill. The free bucket resets monthly on the 1st (UTC) to a platform-configured amount. Subscription and refill buckets are populated through paid flows (Stripe stubs present; paid tier not yet live).
Wallet amounts are markup-included; ledger-cost amounts are not. Every figure on GET /api/credit and in a credit_exhausted body is markup-included — what the user actually paid. The two cost_usd fields on the session response (total_cost_usd and rounds[].cost_usd) are the exception: they report ledger cost, which is markup-exclusive and therefore lower than the corresponding wallet charge. Do not treat the two as interchangeable, and do not reconcile a balance delta against a cost_usd.
Where wallet state surfaces on responses:
- Write-op errors (
credit_exhausted) include the balance breakdown and reset timing needed to understand why the request was rejected — and, when a positive balance was refused, the minimum the round needed (required_usd) and the cheaper rosters that fit (affordable_alternatives). GET /api/creditis the canonical wallet resource — bucket breakdown, reset timing, rollover cap, subscription status, auto-refill state, FIFO debit order.GET /api/sessions/:idround objects carrycost_usd— ledger cost attributed to that round. Note this is markup-exclusive, so it is not the amount debited from the wallet and does not reconcile against a balance delta. To reconcile spend, readGET /api/creditbefore and after; there is no per-transaction debit array on the session response.GET /api/modelsper-modelavailable+unavailable_reason—available: falsewithunavailable_reason: "credit_exhausted"means the wallet is out, not that this particular model is too expensive. The round minimum is checked per request, soavailable: truedoes not promise that a given panel fits a low balance.
Balance is not embedded on GET /api/sessions/:id top-level — that endpoint is high-volume during session polling, and wallet state on a read-heavy path creates cache-coherency and semantic-coupling problems. Use GET /api/credit when you need a fresh balance outside the write-op flow.
Session mode: remote#
POST /api/deliberation creates a remote-mode session — you drive each round, steering between them with typed snippets.
MCP note. MCP is agent-moderated only:
create_deliberationstarts one appendable round, then the agent callswait_for_round, reads the responses and claim map, and decides whether to callappend_round.
Historical: an autonomous (AI-moderated) mode was supported via a
moderator_modelparameter until 2026-05. It was retired. Requests that sendmoderator_modelwill be rejected by the request schema.
You drive the rounds#
The endpoint commits round 1 and returns a 202 ack immediately; model execution runs in the background. Poll the returned progress_url until the round is terminal, read the claim_map, then call POST /api/sessions/:id/rounds with steering snippets to add the next round. Repeat as long as you want — there's no preset cap on remote sessions.
{ "prompt": "..." }
Single-call use case: if you only want one round of three opinions and no follow-up, just don't call append_round. The session ends after round 1.
Platform Improvement Consent#
POST /api/deliberation accepts optional improvement_consent for the new session. This controls whether session data may be used for platform improvement, not billing, credit consumption, model routing, or response visibility.
- Omit it to use the account's effective default.
- Any account may send
trueorfalse. - Session-level consent is narrowing-only: sending
falseexcludes the session even if the account default is on. Sendingtruenever widens beyond the account's own Platform Improvement choice — if the account default is off, the session is still excluded. improvement_consentis session-level.POST /api/sessions/:id/roundsrejects it rather than changing consent mid-session.
Session responses expose the resolved decision:
"improvement_consent": {
"enabled": true,
"reason": "free_tier_terms",
"requested": null,
"disclosure": null
}
Existing sessions created before this field shipped may report reason: "default_include".
Dataset Contribution Consent#
The second election. POST /api/deliberation also accepts optional dataset_consent, with the same rules as improvement_consent: session-level, create-only, narrowing-only.
- Omit it to use the account's Dataset Contribution choice.
falseexcludes the session even if the account opted in.truenever widens beyond the account's own choice. Dataset Contribution is a forced per-account choice, so an account that has not yet made it stays excluded whatever the session requests — the response reportsreason: "not_yet_chosen".POST /api/sessions/:id/roundsrejectsdataset_consent, exactly as it rejectsimprovement_consent.
Session responses expose the resolved decision beside improvement_consent:
"dataset_consent": {
"enabled": false,
"reason": "not_yet_chosen",
"requested": true
}
reason is one of account_default, explicit_include, explicit_exclude, not_yet_chosen, guest_excluded, or pre_election for sessions created before the election existed.
enabled is the stamp recorded at creation, and it is immutable. It says what the session was created under, not whether the session is eligible today: eligibility also requires the account's current Dataset Contribution switch to be on, so a session stamped true stops being eligible the moment the account opts out, while this field keeps reporting true.
Async by default#
As of 2026-04, POST /api/deliberation and POST /api/sessions/:id/rounds return a 202 Accepted ack in <500ms after the round row commits and budget is debited — they do not wait for model execution. Model work runs in the background; callers poll progress_url for terminal state.
What the ack looks like:
{
"session_id": "abc-123",
"round_id": "round-456",
"round_index": 0,
"status": "processing",
"idempotency_key": "auto-gen-uuid-if-omitted",
"client_request_id": null,
"poll_after_ms": 5000,
"progress_url": "/api/sessions/abc-123/progress",
"progress_version": 0
}
The ack confirms that the round committed and tells the caller where to poll progress. The canonical wallet resource with bucket breakdown and reset timing lives at GET /api/credit. Write operations still perform a balance preflight and return credit_exhausted before committing if the wallet is out or cannot cover the round's minimum.
Why: a client timeout no longer produces server/client state divergence. If the ack reached you, the round committed; if it didn't, your idempotency key replay resolves the uncertainty.
?wait=true compatibility shim. REST-only. Appending ?wait=true to the create/append URL makes the endpoint block until terminal state or 290s (whichever first), preserving the pre-2026-04 response shape. This is a migration aid, deprecated (see "Deprecation timeline" below). MCP does not use ?wait=true; use the wait_for_round tool after the write-op ack.
Idempotency#
- Scope:
(account, endpoint, Idempotency-Key). Same key can be used on bothcreate_deliberationandappend_roundwithout colliding. - Request fingerprint: the server hashes a canonical subset of your body (prompt + snippets[] order-sensitive + model set sorted + moderator_name + reference +
improvement_consent+dataset_consent+register+web_search), NFC-normalized. Same key + same semantic body = replay; same key + different semantic body =409 idempotency_conflictwithoriginal_request_fingerprintin the response for caller-side diff. - Auto-generation: MCP always sends one (its adapter derives a stable key from tool+args). REST: if you omit the
Idempotency-Keyheader, the server generates a UUID and echoes it in the ack'sidempotency_keyfield. Persist the echoed key if you want retry safety across transport failures. - Retry during refund: if a round's
refund_status='pending'at replay time, the response includesstatus: "processing_refund"withretry_after_msinstead of the original terminal. Once the refund credits, replays reflect the terminal failure. - TTL: 24h rolling window.
- Novel error patterns: unclassified failures (classifier fell through to
internal_error) do NOT auto-credit refunds. They write the failure fact and sitrefund_status='pending'for admin review. Only canonical codes (see "Failure codes" below) auto-credit.
Refund lifecycle#
When a round fails catastrophically (all providers error), the ledger emits a failure fact + credits back your budget atomically. The rounds[].refund_status lifecycle you'll see on the progress endpoint:
none— happy path, no failure — or a failed round whose refund is still reserved (see below).pending— worker emitted the failure fact (failure_event_atset); ledger is about to credit.credited— refund is in;refund_credited_atstamped. Budget restored.not_applicable— partial-success round (≥1 model returned output); round counted as "delivered."
SLO: p99 latency from failure_event_at to refund_credited_at is < 60s. The reservation window below sits before failure_event_at and is deliberately outside this measurement.
Refunds sit behind the retry budget. A round can be terminally failed while its refund_status is still none: as long as a model has retry budget left, the refund is reserved rather than credited, because crediting closes retries (a credited round rejects further attempts). So on a failed round, refund_status: "none" means "not decided yet," not "no refund owed" — poll until it reaches credited or not_applicable. The refund credits when the retry budget is exhausted, when a failure is classified as one a retry cannot fix, or when the reservation window expires; if a retry succeeds first, the round resolves to not_applicable.
Failure codes#
Canonical failure_code values surfaced on the progress endpoint and in refund-conflict responses:
| Code | Meaning |
|---|---|
model_provider_rate_limit | Provider returned 429 / rate-limit |
model_provider_outage | Provider returned 5xx or was unreachable |
model_provider_oom | Provider returned out-of-memory |
model_output_malformed | Response couldn't be parsed |
model_timeout | Provider call exceeded the deadline |
all_providers_failed | Every participant failed (composite) — fires refund |
dependency_timeout | Upstream (e.g. Brave Search) timed out |
dependency_outage | Upstream dependency 5xx |
dependency_malformed_response | Upstream returned unparseable data |
stuck_reconciled | Reconciliation cron detected a stuck round and emitted synthetic failure |
internal_error | Unclassified — held for admin review, does NOT auto-credit |
test_forced_failure | Admin-only test hook |
Per-model error codes (failed_models[].error_code and /progress models[].error_code)#
Distinct from the round-level failure_code above. Round-level codes describe why the round as a whole failed (typically all-providers composite). Per-model error_code describes why an individual model's call terminated. A round can carry partial_failure completion state with some failed_models[] entries that each have their own error_code, while the round-level failure_code stays null (no refund fires on partial success).
| Code | Meaning | Carries partial_text? |
|---|---|---|
provider_error | Provider returned an error mid- or post-stream (after first byte). Partial text preserved. | Yes |
pre_stream_provider_error | Provider returned a 4xx/5xx HTTP response before the stream opened. Covers auth/malformed/pre-stream rate-limit; treat as non-transient for retry decisions. | No |
stream_ended_without_final_marker | The stream closed without signaling completion — either no done event before EOF, or no terminal chunk at all (no finish_reason, no usage frame). Partial text preserved. | Yes |
empty_completion | The provider returned a terminal completion — usage present, finish_reason normal — whose prose was empty. Typically a reasoning model that spent its budget out-of-band. | No (there is no text) |
internal_deadline_reached | An in-band deadline fired before the model finished. On rounds run through the API/MCP (and the web app's Try again) the per-model deadline is 330s. On rounds streamed in the web app, the stream is cut when the provider sends nothing for 90s or when it reaches 280s in total. Either way, the text the model produced up to the cut is kept. | Maybe (when text arrived before the cut) |
deadline_expired | The 1-min sweep cron found a row past its deadline_at without terminal stream_status and wrote this terminal. Out-of-band counterpart to internal_deadline_reached. | No |
stream_interrupted | Worker restarted while the row was mid-stream. | Maybe |
max_retries_exceeded | Pre-first-byte retry cap hit. Provider was unreachable long enough that no bytes ever rendered. | No |
provider_auth_failure | Provider rejected the request for auth reasons (typically configuration error). Non-transient. | No |
pre_stream_failure | Error thrown before provider.stream() was even called (prompt build, factory, etc.). Non-transient. On rounds run through the API/MCP (and on a web round's "Try again") it also covers a provider that failed before sending anything; a provider that failed partway through its response is provider_error instead. | No |
rate_limit | Provider rate-limited (explicit code path; distinct from a generic 429 routed via pre_stream_provider_error). | No |
canceled | User-initiated abort. | No |
provider_refusal | The model provider's safety system declined the request. Do not retry or rephrase automatically — surface it to the user. | No |
credit_exhausted | Your remaining credit could not cover this model when it was about to run, so it was skipped: no provider call, no charge. Check GET /api/credit; re-running before your balance changes (or the free credit resets) is refused the same way. If every model is skipped this way, the round's failure_code is all_providers_failed. | No |
Retry/abandon classifier (used by MCP wait_for_round's recommended_client_action and a useful default for REST callers too):
- Transient — retry-eligible:
rate_limit,provider_error,internal_deadline_reached,deadline_expired,stream_interrupted,stream_ended_without_final_marker,empty_completion. - Abandon (or escalate):
pre_stream_provider_error,provider_auth_failure,pre_stream_failure,max_retries_exceeded,canceled,provider_refusal,credit_exhausted. These don't usually clear on retry under the same conditions. - A refusal dominates. A failed round with any
provider_refusalorcredit_exhaustedentry isabandon, even when another model failed with a transient code. A partial round (at least one model answered) is unaffected: it staysproceed_with_partial_result.
Deprecation timeline for ?wait=true#
- Day 0–90 from GA: full support,
Deprecation: true+Sunset+Linkresponse headers on every?wait=trueresponse. - Day 90–120: advisory window; headers remain.
- Day 120+: returns
410 Gonewith a pointer to the async-polling pattern. Enforced viaWAIT_ENFORCE_410env flag.
Round Takeaway artifacts (opt-in, per-round)#
One optional boolean opts rounds in to per-round Takeaway generation. POST /api/deliberation (the create path) and POST /api/sessions/:id/rounds (append) both accept takeaway.
| Field | Accepted on | Default | Notes |
|---|---|---|---|
takeaway | create + append | false | Generate a round_takeaway artifact when this round completes — a structured per-round summary (bottom_line + items[] of { question, answer, consensus, claim_ids }). Surfaces on GET /api/sessions/:id once written. |
Pricing. The Takeaway bills via the standard credit wallet but with 0 bps markup — at-cost passthrough. The per-session breakdown at /settings/sessions surfaces a dedicated line item with the bucket scope and at-cost marker so you can reconcile what was charged.
Artifacts on the session response. When a Takeaway exists, it surfaces on the session response:
rounds[].round_takeaway— the sole per-round summary artifact, for every source (web, x, api, mcp). Populated for any round that opted in viatakeaway=trueonce generation has completed;nullotherwise.
Legacy distill removed. The former distill request parameter is no longer accepted and does not appear in responses. Use takeaway for per-round Takeaways.
The session response#
Every GET /api/sessions/:id response carries these top-level fields:
| Field | Notes |
|---|---|
id | Session UUID. |
status | streaming | processing | ready | failed. See "Status flow" below. |
mode | remote. REST metadata describing how the session was created. (Historical sessions may report autonomous; that mode was retired in 2026-05.) |
active_models | Model IDs participating in this session. |
moderator_model | Null for all new sessions. Historical sessions may carry a value. |
moderator_name, application | Optional identity metadata. |
model_metadata | { [model_id]: { display_name, provider } }. |
created_at, estimated_ready_at | Timestamps. |
total_usage | Aggregated tokens_in / tokens_out across the session. |
total_cost_usd | Ground-truth ledger cost (USD) for the entire session. Sums every billable bucket: deliberation + moderator + round summary (Takeaway) + snippet extraction + editorial + search. Markup-exclusive — distinct from wallet debits, which are markup-included. 0 for sessions with no ledger rows yet. |
rounds | Array of round objects (see below). |
summary | Session-level editorial. Null until generated for multi-round sessions. |
confidence_disclaimer | Verbatim advisory string. Surface alongside any displayed confidence scores. |
share_url | Public reader-deck URL (https://mumo.chat/p/{slug}) when the session has an active share, else null. Read parity for POST /api/sessions/:id/share — re-find an existing link without re-sharing. |
share_status | shared (link-access only, not indexed) | published (platform-listable) | null (never shared, or share removed). |
Each round in rounds[] carries: id, index, prompt, completion_state, responses, failed_models, in_progress_models, claim_map, post_round_status, claim_map_url, round_takeaway, cost_usd. round_takeaway is the sole per-round summary artifact for all sources; it is null unless the round opted in via takeaway=true (and generation has completed) — see Round Takeaway artifacts.
completion_state (per-round; distinct from session-level status) is 4-way:
| Value | Meaning |
|---|---|
complete | Every target model produced a final response. |
partial_failure | All target models reached terminal state; at least one final AND at least one errored. Round is usable but degraded. |
failed | All target models reached terminal state; every one errored, zero finals. Round produced no usable output. |
in_progress | At least one target model is still queued, streaming, or expected-but-absent. Round not yet settled — keep polling /progress. |
responses[] is the success-only collection: each entry has the canonical content plus three fields for downstream branching:
is_partial(boolean) —truewhen the response is a successful-but-truncated stream (the model produced output and the call reacheddone, but the provider signaled truncation viafinish_reason). Treat the text as a partial answer; consider asking the user whether to extend.finish_reason(string | null) — provider-native stop reason, surfaced as-is rather than normalized (Anthropic:end_turn/max_tokens/stop_sequence; OpenAI:stop/length/content_filter; Gemini:STOP/MAX_TOKENS/SAFETY).nullwhen the stream did not complete naturally (error, abort, deadline). The refusal values —refusal,content_filter,SAFETY,PROHIBITED_CONTENT,BLOCKLIST,SPII,IMAGE_SAFETY— mean the provider's safety system stopped the output partway: the text is what the model produced before it stopped, andis_partialstaysfalse, so branch onfinish_reason. Do not retry or rephrase automatically; surface it to the user. A refusal before any output lands infailed_models[]witherror_code: "provider_refusal"instead.output_cap({ tokens, reason } | null) — set when a platform cap bound this call's output length;nullotherwise (always present).tokensis the output-token ceiling the cap imposed;reasonisguest(the guest output limit) orbalance(your remaining credit bounded the output). Pairs withis_partial/finish_reason: a truncated response with a non-nulloutput_capwas cut by the platform cap, not the model's own limit. Onbalance, top up or wait for the credit reset rather than retrying.
failed_models[] is the error-attribution collection. Each entry:
| Field | Notes |
|---|---|
model | Model ID that failed. |
error | Free-text error description from the row's error column. Stable but not safe to pattern-match — switch on error_code for branching. |
message | Human-readable error message. |
error_code | Canonical STREAM_ERROR_CODES value (provider_error, stream_ended_without_final_marker, internal_deadline_reached, …). null on legacy rows. See "Per-model error codes" below. |
output_cap | { tokens, reason } | null, always present — same meaning as on responses[]. A call a platform cap left with no usable text lands here with it set; on reason: "balance", top up or wait for the credit reset rather than retrying. |
partial_text | Optional. Present when the failed call emitted text before terminating (post-first-byte provider_error — on API/MCP rounds too, when the provider failed partway through its response — stream_ended_without_final_marker, and internal_deadline_reached). Diagnostic value; sometimes usable as a partial answer. |
partial_text_length | Optional. Character count of partial_text when present. |
in_progress_models[] is the "still working" collection — present when completion_state === "in_progress". Each entry:
| Field | Notes |
|---|---|
model | Target model ID. |
state | queued (row pre-inserted, provider call not yet started), streaming (≥1 delta observed, no terminal yet), or absent (rare; backstop window or race against pre-insert). |
deadline_at | ISO 8601 timestamp at which the sweep cron will write a terminal error if the row hasn't transitioned. null on legacy rows. |
cost_usd is the per-round counterpart of session-level total_cost_usd — same ledger source, same markup-exclusive semantics. It is useful after a round completes; during an in-flight round it may be 0 or incomplete because ledger rows settle as model/finalizer calls finish. The relationship: sum(rounds[].cost_usd) ≤ total_cost_usd — session-scoped buckets (session title generation, editorial summary) appear in total_cost_usd only.
The per-round artifacts:
-
responses[].text— raw prose from each model. -
responses[].snippets[]— model-emitted reactions (typedKEEP/CHALLENGE/etc, with verbatim quotes from peers and optional commentary). -
claim_map.claims[]— verbatim claims that ≥2 models reacted to, with each reactor'sposition(type + commentary). The highest-signal artifact for understanding agreement and disagreement. Apositioncan also be the moderator (model: "moderator"): when a later round's moderator snippet reacts to this round's content, that reaction backfills onto the prior round's claim here. A moderator reaction can also surface a claim that no two models cross-reacted on (a moderator-only claim), so don't assume every claim has ≥2 model positions. -
post_round_status— the state of the round's reactions step (reactions → claim map → takeaway), so a nullclaim_mapis never ambiguous:Value Meaning pendingStill being generated, or the responses are still running. Keep polling. readyclaim_maphas claims.no_claimsReactions finished with nothing to map (typical for a one-model round), or the round has no reactions step. failedThe claim map failed permanently for this round. skipped_balanceThe reactions and the takeaway (if requested) were skipped because the account's balance could not cover them; the account owner can generate them from the web app after adding credit. claim_mapandround_takeawaystaynull. -
claim_map_url— browser URL for this round's claim map (https://mumo.chat/cm/{round_id}). Auth-gated and owner-only: it requires signing in with the mumo account that owns the API key, then opens the claim map inside the full session view (alongside the raw responses with click-through to the inline text). Agents should surface it to the human at the end of their summary so the deliberation can be reviewed directly. Always present.
Agents driving remote-mode deliberations should use claim_map to decide whether to continue or stop, and opt in to takeaway when they want structured per-round summaries.
The session-level summary field carries the final editorial across the whole session (surface, agreed, split, open blocks plus anchor_quote and og_quote). It's only populated for completed multi-round legacy sessions.
Endpoints#
POST /api/deliberation#
Create a session. Body:
| Field | Type | Notes |
|---|---|---|
prompt | string | The question or topic. Required. |
reference | string | Optional spec, doc, or design injected as shared context. |
models | string[] | 2–3 model IDs. Omit it and the platform curates a panel of up to your plan's seat cap from the models your plan can run. Call GET /api/models to enumerate. |
moderator_name | string | Display name for the steering identity (≤100 chars). Surfaces in the published transcript. |
application | string | Display name of your client (≤100 chars). Surfaces in the session info panel. |
takeaway | boolean | Opt round 0 in to a per-round Takeaway (round_takeaway). Default false. See Round Takeaway artifacts. |
register | "conversational" | "agent" | Deprecated — ignored. The platform runs a single prompt environment for all sessions. The field is still accepted (and still participates in the idempotency fingerprint) so existing callers' retries stay stable. Omit it in new integrations. |
web_search | boolean | Set false to skip web search entirely (no search gate, no source pack). Default true — the platform decides per-prompt whether to search. Disable for prompts grounded purely in supplied reference material. |
Returns a session object (see Quickstart for an example shape).
Idempotency: pass Idempotency-Key: <stable-string> to make retries safe. Same key + same body returns the cached response; same key + different body returns 409 idempotency_conflict.
POST /api/sessions/:id/rounds#
Append a round to a remote-mode session. Body:
{
"prompt": "Focus on the pricing mechanism, not positioning.",
"snippets": [
{
"type": "CHALLENGE",
"quote": "Per-seat pricing assumes teams of >10.",
"quoted_model": "gpt-5.4",
"comment": "Most enterprise pilots start at 3–5."
},
{
"type": "KEEP",
"quote": "Usage-based pricing aligns incentives.",
"quoted_model": "claude-opus-4-6"
}
],
"takeaway": false
}
snippets is optional but high-signal — it's how you steer attention round-to-round.
takeaway is an optional boolean (default false) that yields a round_takeaway for this round (see Round Takeaway artifacts).
Snippet types:
- KEEP — this point is strong; preserve it
- EXPLORE — dig deeper here
- CHALLENGE — push back on this claim
- CORE — load-bearing; build on it
- SHIFT — this reframes the question
Quotes must be verbatim from a prior round's response. quoted_model is the model ID that originated the quote.
Idempotency-Key is strongly recommended on this endpoint. Round-append duplication corrupts deliberation history.
Errors:
409 session_busy— a round is currently streaming or processing. Retry after a short delay with the sameIdempotency-Key.403 credit_exhausted— your wallet balance is exhausted (zero or negative), or is positive but cannot cover the minimum for a round over the session's active models. Body includeseffective_balance_usd,free_usd,subscription_usd,refill_usd,per_model_minimum_usd,next_reset_at; a positive-balance refusal addsrequired_usdandaffordable_alternatives, which is always empty on an append (see the body). An append cannot change the session's roster, so to run a cheaper roster start a new deliberation with it. Free-tier balance resets on the 1st of each month (UTC).403 forbidden_model— a model in the session's panel is not included in your plan. Body includesreason,denied_models,upgrade_url,human_action_required: true. Only a plan change by the account's owner admits the round (see the bodies).403 panel_seat_limit— the session's panel seats more models than your plan allows. Body includesseat_limit,requested_seats,upgrade_url,human_action_required: false. An append cannot change the roster, so start a new deliberation with at mostseat_limitmodels.413 input_too_long— the new text in this request (thepromptplus yoursnippets' quotes, comments andquoted_models) is longer than your plan allows. Body includestyped_tokens,cap,upgrade_url,human_action_required: false. Shorten it and send again (see the bodies).400 unknown_models— the session's panel names a model that is no longer in the registry. Body includesunknown_models.400 attachments_web_only— the session has documents attached in the mumo web app. A session with attached documents can only be continued in the web app; start a new deliberation to continue through the API. Not retryable.
GET /api/sessions/:id#
Fetch the full state of a session — all rounds, responses, snippets, claim maps, and the editorial summary if present.
The response is fresh on every call.
POST /api/sessions/:id/share#
Share a session at a public URL — anyone with the link can view it in the read-only reader deck. No request body.
{
"session_id": "…",
"status": "shared",
"slug": "your-session-title-a1b2c3",
"share_url": "https://mumo.chat/p/your-session-title-a1b2c3",
"markdown_url": "https://mumo.chat/p/your-session-title-a1b2c3.md",
"brief_url": "https://mumo.chat/p/your-session-title-a1b2c3.brief.md"
}
markdown_url is the full-transcript machine-readable twin (per-round claim maps included — the review/audit surface); brief_url is the synthesis-only triage tier (~1–2k tokens, predictable cost).
Semantics:
- Idempotent — re-calling returns the same slug and URLs.
- Snapshot, kept current — the public page is a point-in-time snapshot; a share call made after new rounds were appended refreshes it at the same URL.
- Link-access only — shared pages are
noindexand unlisted.statusstayssharedunless the platform has separately elevated the artifact; a share call never changes an elevated status. - Registered accounts only — anonymous callers receive
403 registration_required. - The first share of a long session generates any missing round takeaways before freezing the snapshot (~15–30s worst case). A
500 share_failedwhose message says summaries are still generating is transient — retry after a moment.
Errors: 403 registration_required, 404 not_found, 500 share_failed.
GET /api/sessions/:id/progress#
Lightweight poll endpoint for round state without fetching the full session body. Two consumers:
- Async REST/MCP callers — after a
create_deliberation/append_roundack, poll this to learn whether the round reached terminal state (and whether the refund lifecycle moved). The terminal check is:moderation_statusis"complete"or"failed", orexecution_statusis"complete"or"failed". Rounds created from the mumo web UI never writemoderation_status(it staysnull) —execution_statusis their only terminal signal, so a moderation-only check polls forever on web-created rounds. Either terminal value is an end-state; stop polling."failed"can mean an all-models error OR a catastrophic round-level failure (e.g., pre-insert / internal pipeline error) — to distinguish, readfailure_codeand the per-modelstate/error_codeentries inmodels[]. Once terminal, fetch the full content viaGET /api/sessions/:id, whose round objects carryfailed_models[]for end-of-poll attribution. - Real-time UIs — surface per-model state (queued / streaming / final / error / absent) and a heartbeat without re-rendering the whole transcript.
The response is intentionally compact (no joins on responses text, snippets, or claim map):
{
"session_id": "abc-123",
"is_ai_moderated": false,
"auto_moderation_completed_at": null,
"rounds": [
{
"id": "round-456",
"index": 0,
"moderation_status": "in_progress",
"execution_status": "running",
"refund_status": "none",
"failure_code": null,
"failure_event_at": null,
"refund_credited_at": null,
"refund_deadline_at": null,
"progress_version": 3,
"models": [
{
"model": "claude-opus-4-7",
"state": "streaming",
"deadline_at": "2026-05-13T03:02:30Z",
"error_code": null,
"expired_at_read": false,
"provider": "anthropic",
"inference_provider": "anthropic",
"partial_text_length": 1840,
"last_chunk_at": "2026-05-13T03:00:07Z",
"since_last_chunk_ms": 3000
}
]
}
]
}
Round-level status fields:
| Field | Notes |
|---|---|
moderation_status | Legacy round lifecycle: pending | in_progress | complete | failed | null. Stamped and settled by API/MCP-created rounds; always null on web-created rounds. |
execution_status | Go-forward round lifecycle. Non-terminal: created | running | reclaimable | cancelling. Terminal: complete | failed | cancelled. (cancelling/cancelled are defined by the schema but have no writer yet — no round currently reaches them.) Written terminal by every settle path, including web-created rounds. For web rounds, terminal here means all model responses have settled — round artifacts (claim map, takeaway) may still be generating for a short window afterward. |
Per-model fields:
| Field | Notes |
|---|---|
model | Model ID. |
state | final | error | streaming | queued | absent. Same state machine as in_progress_models[].state plus terminal values. |
deadline_at | ISO 8601 timestamp at which the row will be swept to a terminal error if it hasn't transitioned. null on legacy rows. |
error_code | STREAM_ERROR_CODES value when state === "error". null otherwise. |
expired_at_read | true when state is queued or streaming AND deadline_at is already past at read time. The sweep cron's ~60s cadence means a row can be expired up to that long before its terminal write lands; this field exposes the derived state immediately so callers can render "this model is past its deadline; result expected within a minute." Always false on terminal states. |
provider | Model family (anthropic | openai | google | xai | moonshot | zai | alibaba). null when the registry lookup fails. |
inference_provider | Inference endpoint family — distinct from provider when the model routes through a third-party inference host (e.g., Kimi via Fireworks). Matches provider when no cross-provider route is active. |
partial_text_length | Character count of the response's accumulated text. null for queued / absent rows; 0 for streaming rows that haven't flushed yet. Use null-vs-0 to distinguish "not producing yet" from "observed zero-length partial." For terminal final / error rows, this is the final character count. |
last_chunk_at | ISO 8601 timestamp of the most recent SDK delta. null on current rows, because no per-delta progress is recorded. Only older rows carry a value. |
since_last_chunk_ms | Read-time computed: now - last_chunk_at. Only present when state === "streaming"; null otherwise. Combined with partial_text_length this renders as "claude: streaming, 1840 chars, last chunk 3s ago." |
ETag / If-None-Match support. Every /progress response carries a weak ETag. Send it back on the next poll via If-None-Match to short-circuit no-change polls with a 304 Not Modified. The validator covers every body-derived signal — round-level state, per-model state, registry attribution, and (when any model is non-terminal) a 5-second wall-clock bucket so since_last_chunk_ms and expired_at_read can't go stale past one bucket boundary. Terminal-only payloads keep their state-based ETag stable across reads, so cache hits on completed rounds are long-lived.
cache-control: no-store on every response — clients shouldn't share-cache, but their own If-None-Match re-poll still works.
GET /api/sessions#
List your sessions.
| Query | Values |
|---|---|
mode | remote (historical sessions may also report autonomous) |
status | ready | streaming |
limit | 1–200 (default 7) |
offset | pagination |
Returns a lightweight list (no response bodies). Use GET /api/sessions/:id for full content.
GET /api/models#
List the full model catalog — every registry-active model, whatever your credentials. available reports whether you can use each one right now; nothing is filtered out. Each entry:
{
"id": "claude-opus-4-6",
"provider": "anthropic",
"display_name": "Claude Opus 4.6",
"available": true,
"unavailable_reason": null,
"min_user_tier": 1,
"context_window": 200000,
"max_output_tokens": 16384,
"pricing": {
"input_per_million": 15,
"output_per_million": 75,
"cached_input_per_million": 1.5,
"minimum_usd": 0.05,
"cache_write_per_million": 18.75
},
"sort_order": 10
}
available—trueif the caller can actually use this model right now through this API. That means all of: anmmo_live_*key, a plan that includes the model, and credit remaining — the same things/api/deliberationchecks before it runs anything. Any other caller (no bearer, or a browser/anonymous session token) getsfalseon every model, since the execution endpoints reject them regardless of which model they name.falsemeans a call that uses this model will fail.unavailable_reason— whyavailableis false, ordered by what to fix first."api_key_required"— nommo_live_*key was presented."membership_required"— your plan does not include this model; a request naming it is refused with403 forbidden_model, whatever your balance."credit_exhausted"— the wallet is zero or negative. A positive balance reports the model available even if it cannot cover a particular panel; that minimum is checked per request. Absent whenavailableis true.min_user_tier— account tier:0= Everyday,1= Plus,2= Premium. Every API caller is a registered account. Every tier is listed for every caller — the catalog is public, andavailablereports whether this caller can use a given model. Credit balance is the runtime constraint.pricing.minimum_usd— the model's registered floor cost, reported for reference. It is not an admission threshold: preflight prices a round from each model's reserved output room and token rates, whatever this value is. It still appears in thecredit_exhaustedbody asper_model_minimum_usd.pricing.cache_write_per_million— present only on models that bill prompt-cache writes: Anthropic models and the GPT-5.6 family (both at 1.25× the input rate). When absent, cache writes are not billed for that model.- Per-million pricing fields reflect raw provider cost (platform COGS), not markup-inclusive user-paid amounts. They're informational — for "will this work?" preflight, use
available(oreffective_balance_usd > 0fromGET /api/credit) — both are necessary, not sufficient: a low positive balance can still refuse a particular panel withcredit_exhausted. For "what did that cost?" compareeffective_balance_usdfromGET /api/creditbefore and after the round; the round'scost_usdreports ledger cost, which is markup-exclusive and so smaller than the wallet debit.
GET /api/credit#
Canonical wallet resource. Returns the caller's full credit state.
{
"effective_balance_usd": 1.42,
"buckets": {
"free": {
"balance_usd": 1.42,
"monthly_grant_usd": 1.50,
"resets_at": "2026-05-01T00:00:00Z"
},
"subscription": {
"balance_usd": 0,
"rollover_cap_usd": 30.00,
"subscription_status": null
},
"refill": {
"balance_usd": 0,
"auto_refill_enabled": false
}
},
"per_model_minimum_usd_default": 0.05,
"debit_order": ["free", "subscription", "refill"]
}
effective_balance_usd— sum across all three buckets. This is the preflight number: zero or negative iscredit_exhaustedfor every write; above zero, each write also needs the balance to cover that request's round minimum, which depends on the models requested. Markup-included — reflects what you have left to spend, not raw LLM-cost headroom.buckets.free— monthly free-tier credit.monthly_grant_usdis the platform grant each cycle;resets_atis the next UTC 1st-of-month boundary when the bucket refills.buckets.subscription— Stripe-granted credit (paid tier).rollover_cap_usdis the max unused balance that carries forward into a new cycle.subscription_statusis one of"active" | "past_due" | "cancelled" | "expired" | null(null = no subscription).buckets.refill— auto-refill top-ups.auto_refill_enabledis the user's current setting. When enabled, two additional fields appear:auto_refill_threshold_usd(trigger level) andauto_refill_amount_usd(top-up size). The fields are absent when autorefill is off.per_model_minimum_usd_default— platform fallback minimum used when a model's registry row has no explicitpricing_minimum_usd. Reported for reference only; it does not gate admission. For per-model values, readpricing.minimum_usdfromGET /api/models.debit_order— FIFO debit sequence. Settlement drains each bucket in this order; surfaced so dashboards and reconciliation tooling don't need to read server code.
Anonymous callers receive the same shape with all balances at 0, subscription_status: null, and auto_refill_enabled: false. Anonymous usage is gated by guest-round budget elsewhere, not the credit wallet.
GET /api/defaults#
Discover platform defaults. Wallet state is not here — use GET /api/credit.
{
"models": ["…", "…", "…"],
"daily_budget": { "limit": 200, "used": 4, "resets_at": "…" }
}
models holds up to three ids, never more than your plan seats in one round (so it can hold two), each one your plan can run.
GET /api/health#
Returns { "status": "ok", "version": "v1" }. No auth required.
Status flow#
Two distinct state machines surface in the response.
Session-level status — high-level rollup; what to switch on in REST polling loops:
streaming → processing → ready
↘
failed
- streaming — at least one model in the most recent round is actively responding.
- processing — all model responses landed; post-processing (snippet extraction, claim map, optionally the round Takeaway) is in flight.
- ready — fully complete; safe to call
append_roundor read final artifacts. - failed — terminal error.
Per-round completion_state — finer-grained signal that lives on each rounds[] entry; the canonical "is this round usable yet?" check:
in_progress → complete | partial_failure | failed
- in_progress — ≥1 target model still queued / streaming / absent.
- complete — every target model produced a final response.
- partial_failure — every target model is terminal; ≥1 succeeded, ≥1 errored. Round is usable.
- failed — every target model is terminal; zero succeeded. No usable output.
Note: a session can be status: "processing" (post-execution finalizer running) while its latest round is already completion_state: "complete". The session moves to ready once the post-processing pipeline finishes. For "is the round content available?" — check completion_state. For "is the session settled (including editorial/summary)?" — check status.
Errors#
All non-2xx responses return JSON:
{
"error": "session_busy",
"message": "Session has a round in progress — poll and retry with same Idempotency-Key",
"retryable": true
}
Every field above is always present: error is a stable machine code, message
is human-readable prose, and retryable says whether repeating the identical
request could succeed. Some codes add typed extras (documented per row below);
treat unknown extra keys as forward-compatible additions.
| Code | HTTP | Retryable | What to do |
|---|---|---|---|
unauthorized | 401 | no | Bad or missing bearer token. |
malformed_json | 400 | no | Request body was not parseable JSON. |
invalid_request | 400 | no | Body failed schema validation. Body includes issues — the Zod flatten shape, with fieldErrors keyed by field name and formErrors for whole-body problems. |
invalid_params | 400 | no | A path parameter failed validation (e.g. an empty session id). Body includes issues in the same shape. |
unknown_models | 400 | no | One or more model IDs in the request (on an append, in the session's panel) don't exist in the registry. Checked before the plan and credit checks. Body includes unknown_models: string[], plus models_requested: string[] on POST /api/deliberation. Call GET /api/models to enumerate valid IDs. |
attachments_web_only | 400 | no | POST /api/sessions/:id/rounds on a session that has documents attached in the mumo web app. Such a session can only be continued in the web app; start a new deliberation to continue through the API. |
insufficient_active_models | 400 | no | The caller omitted models and the curated default panel couldn't produce ≥2 picks from the active registry. Body includes collapsed_buckets: number[] (zero-indexed). Pass an explicit models array. |
credit_exhausted | 403 | no | Wallet balance is exhausted (zero or negative), or is positive but below the requested round's minimum. Body includes bucket breakdown + reset timing, plus required_usd and affordable_alternatives on a positive-balance refusal (see below). Retry with a roster that fits, top up (paid), or wait for the 1st-of-month free-tier reset. |
forbidden_model | 403 | no | A requested model is not included in your plan (POST /api/deliberation, POST /api/sessions/:id/rounds). Body includes reason, denied_models, upgrade_url, and human_action_required: true — see below. Drop the model, or have the account's owner change plan. |
panel_seat_limit | 403 | no | The round seats more models than your plan allows (same endpoints). Body includes seat_limit, requested_seats, upgrade_url, and human_action_required: false. Send at most seat_limit models. |
input_too_long | 413 | no | The request's new text is longer than your plan allows (same endpoints): on POST /api/deliberation the prompt plus reference, on an append the prompt plus your snippets. Body includes typed_tokens, cap, upgrade_url, and human_action_required: false. Shorten the text to at most cap. |
forbidden | 403 | no | Feature not available on your account. |
terms_acceptance_required | 403 | no | Updated terms need acceptance before the platform can be used. Body includes a terms_acceptance_required object with doc_types_pending, current_versions, deadline, summary_urls, and acceptance_url. |
region_unavailable | 403 | no | mumo is currently available in the US and Canada (Quebec excluded). Judged by the calling machine's network location — an agent host or CI runner outside the allowlist is refused even if the key owner is in the US. Body includes scope (country or region). Applies to every authenticated REST endpoint except GET /api/models; MCP tool calls are refused in-band instead (see the MCP docs). |
account_suspended | 403 | no | The key owner's account is suspended. Returned on every authenticated REST request made with a platform key (reads included) except GET /api/models; message carries the explanation and the support contact. Do not retry, and do not rotate or create keys to work around it — surface the message to the user. Checked before the location check. |
not_found | 404 | no | Session ID doesn't exist or isn't yours. The 403 "exists but not yours" case is deliberately collapsed into this code so a caller cannot probe for the existence of someone else's session. Also returned for any path under /api/ that matches no endpoint — that body carries docs_url, openapi_url, and mcp_url so a mistyped path points you somewhere useful. |
idempotency_conflict | 409 | no | Same key reused with a different body. Use a new key. |
session_busy | 409 | yes | Another round is in flight. Retry with the same Idempotency-Key. |
wait_sunset | 410 | no | The removed ?wait=true synchronous mode. Create the round, then poll GET /api/sessions/:id/progress. |
rate_limited | 429 | yes | Too many appends too quickly. Body includes retry_after_seconds. |
daily_limit_reached | 429 | yes | Round budget exhausted for the period. Body includes resets_at. |
internal_error | 500 | yes | Transient. Retry with the same Idempotency-Key. |
credit_exhausted body#
{
"error": "credit_exhausted",
"message": "Your balance is insufficient to run this request.",
"retryable": false,
"effective_balance_usd": 0,
"free_usd": 0,
"subscription_usd": 0,
"refill_usd": 0,
"per_model_minimum_usd": 0.05,
"next_reset_at": "2026-05-01T00:00:00Z"
}
Unlike session_busy / internal_error, this is not transient — retrying the same request won't resolve it. Either top up (when paid tier ships) or wait for next_reset_at. The bucket breakdown is included here because an exhausted caller needs to know which bucket is empty and when funds return. per_model_minimum_usd is reported for reference — the rejection is never on that figure.
When the balance is positive but can't cover the requested round's minimum, the same 403 adds two fields:
{
"error": "credit_exhausted",
"message": "Your balance is insufficient to run this request.",
"retryable": false,
"effective_balance_usd": 0.4,
"free_usd": 0.4,
"subscription_usd": 0,
"refill_usd": 0,
"per_model_minimum_usd": 0.05,
"next_reset_at": "2026-05-01T00:00:00Z",
"models_requested": ["model-a", "model-b", "model-c"],
"required_usd": 0.62,
"affordable_alternatives": [
{ "kind": "drop_most_expensive", "models": ["model-b", "model-c"], "dropped": "model-a" },
{ "kind": "cheapest_solo", "models": ["model-c"] }
]
}
required_usd— the minimum the requested round needs before it can start: each model's reserved output room plus the input, plus the post-round reactions and takeaway, plus any credit your in-flight rounds already hold. It is a minimum, not the expected cost — the round's actual cost can be higher or lower. Web search is not part of it: a round your balance covers without search is admitted, and runs without web search when the balance can't also cover the search.affordable_alternatives— rosters built from the requested models whose minimum fits the balance now, in a fixed order; empty when none fits. Populated only on a create refusal (POST /api/deliberation); always empty on an append refusal (POST /api/sessions/{id}/rounds), because an append cannot change the session's roster.kindis"drop_most_expensive"(the panel minus its single most expensive model, named indropped; offered for 3-model panels — passmodelsto a newPOST /api/deliberation) or"cheapest_solo"(the single cheapest model;POST /api/deliberationneeds 2–3 models, so this tells you the balance covers one model, not a request this API can run).modelsare model IDs.
Both fields are absent on a zero-or-negative refusal. models_requested appears on POST /api/deliberation refusals only.
forbidden_model and panel_seat_limit bodies#
Plan refusals from POST /api/deliberation and POST /api/sessions/:id/rounds. Each is checked before the credit wallet and never touches it; a request that is both is answered forbidden_model, and a request refused for its models or seats is not also judged for its length. None is retryable as-is.
{
"error": "forbidden_model",
"message": "One or more of these models isn't available on your plan.",
"retryable": false,
"reason": "membership_required",
"denied_models": ["claude-opus-5-5"],
"upgrade_url": "https://mumo.chat/pricing",
"human_action_required": true
}
{
"error": "panel_seat_limit",
"message": "Your plan allows up to 2 models in a round.",
"retryable": false,
"seat_limit": 2,
"requested_seats": 3,
"upgrade_url": "https://mumo.chat/pricing",
"human_action_required": false
}
reason(forbidden_model) —"membership_required": the model is outside your plan. The field shares a vocabulary with other surfaces that also use"guest_model_unavailable"and"account_tier_required"; treat any value as "not on this plan".denied_models(forbidden_model) — the requested model IDs your plan does not include.GET /api/modelsreports each asunavailable_reason: "membership_required".seat_limit/requested_seats(panel_seat_limit) — the most models your plan seats in one round, and how many the request asked for.typed_tokens/cap(input_too_long, HTTP 413) — the size of the request's new text and the most your plan accepts, both in tokens counted as characters ÷ 4 (rounded up). The text counted is what you send: theprompt, plusreferenceon a create and thesnippets'quote,commentandquoted_modelon an append. Session history and search results are never counted.upgrade_url— where the account's owner changes plan.human_action_required—trueonforbidden_model: only the account's human can change the plan, so an agent should surface it rather than loop.falseonpanel_seat_limit: send at mostseat_limitmodels (on an append, start a new deliberation, since an append cannot change the roster).falseoninput_too_long: shorten the text to at mostcap.
{
"error": "input_too_long",
"message": "This message is longer than your plan allows (about 12000 tokens of new text per round).",
"retryable": false,
"typed_tokens": 13001,
"cap": 12000,
"upgrade_url": "https://mumo.chat/pricing",
"human_action_required": false
}
The example values are illustrative; read seat_limit and cap from the body rather than assuming a number.
unknown_models body#
{
"error": "unknown_models",
"message": "One or more model IDs are not in the registry.",
"retryable": false,
"unknown_models": ["gpt-typo"],
"models_requested": ["claude-opus-4-6", "gpt-typo"]
}
Preflight check. Fails before any provider call is made, so no credit is debited.
Confidence scores#
When models emit self-reported confidence (via {{C=0.8}}…{{/C}} tags in their prose, or on snippet commentary), those scores surface on responses:
responses[].claim_confidence: [{ claim_text, confidence_score }]— per-claim scores extracted from prose. Tags are stripped fromtextbefore return.responses[].snippets[].comment_confidence: number | nullconfidence_disclaimer: string— verbatim advisory at the top of every session response.
These are self-reported and only meaningful relative to the same model's other claims. They are not calibrated across models. If you display them, surface the disclaimer too.
Naming philosophy#
Field names you see in API requests and responses are the canonical contract — they're the names mumo guarantees to consumers. Internal type names and DB columns may differ; the serializer maps between them. We follow a contract-first principle and one-way mapping (internal → API), with the full mapping documented in docs/CONVENTIONS.md.
One convention worth knowing up front: snippet types (type field) are always UPPERCASE at the API boundary — KEEP, EXPLORE, CHALLENGE, CORE, SHIFT. They're lowercase only in internal storage.
We don't rename API fields casually. Any boundary rename comes with a deprecation period that accepts the old name as alias.