Contents

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#

  1. Get a key at mumo.chat/settings/api-keys. Keys begin with mmo_live_.
  2. 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/credit is the canonical wallet resource — bucket breakdown, reset timing, rollover cap, subscription status, auto-refill state, FIFO debit order.
  • GET /api/sessions/:id round objects carry cost_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, read GET /api/credit before and after; there is no per-transaction debit array on the session response.
  • GET /api/models per-model available + unavailable_reason — available: false with unavailable_reason: "credit_exhausted" means the wallet is out, not that this particular model is too expensive. The round minimum is checked per request, so available: true does 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_deliberation starts one appendable round, then the agent calls wait_for_round, reads the responses and claim map, and decides whether to call append_round.

Historical: an autonomous (AI-moderated) mode was supported via a moderator_model parameter until 2026-05. It was retired. Requests that send moderator_model will 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.


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 true or false.
  • Session-level consent is narrowing-only: sending false excludes the session even if the account default is on. Sending true never widens beyond the account's own Platform Improvement choice — if the account default is off, the session is still excluded.
  • improvement_consent is session-level. POST /api/sessions/:id/rounds rejects 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".

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.
  • false excludes the session even if the account opted in.
  • true never 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 reports reason: "not_yet_chosen".
  • POST /api/sessions/:id/rounds rejects dataset_consent, exactly as it rejects improvement_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 both create_deliberation and append_round without 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_conflict with original_request_fingerprint in 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-Key header, the server generates a UUID and echoes it in the ack's idempotency_key field. 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 includes status: "processing_refund" with retry_after_ms instead 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 sit refund_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_at set); ledger is about to credit.
  • credited — refund is in; refund_credited_at stamped. 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:

CodeMeaning
model_provider_rate_limitProvider returned 429 / rate-limit
model_provider_outageProvider returned 5xx or was unreachable
model_provider_oomProvider returned out-of-memory
model_output_malformedResponse couldn't be parsed
model_timeoutProvider call exceeded the deadline
all_providers_failedEvery participant failed (composite) — fires refund
dependency_timeoutUpstream (e.g. Brave Search) timed out
dependency_outageUpstream dependency 5xx
dependency_malformed_responseUpstream returned unparseable data
stuck_reconciledReconciliation cron detected a stuck round and emitted synthetic failure
internal_errorUnclassified — held for admin review, does NOT auto-credit
test_forced_failureAdmin-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).

CodeMeaningCarries partial_text?
provider_errorProvider returned an error mid- or post-stream (after first byte). Partial text preserved.Yes
pre_stream_provider_errorProvider 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_markerThe 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_completionThe 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_reachedAn 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_expiredThe 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_interruptedWorker restarted while the row was mid-stream.Maybe
max_retries_exceededPre-first-byte retry cap hit. Provider was unreachable long enough that no bytes ever rendered.No
provider_auth_failureProvider rejected the request for auth reasons (typically configuration error). Non-transient.No
pre_stream_failureError 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_limitProvider rate-limited (explicit code path; distinct from a generic 429 routed via pre_stream_provider_error).No
canceledUser-initiated abort.No
provider_refusalThe model provider's safety system declined the request. Do not retry or rephrase automatically — surface it to the user.No
credit_exhaustedYour 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_refusal or credit_exhausted entry is abandon, even when another model failed with a transient code. A partial round (at least one model answered) is unaffected: it stays proceed_with_partial_result.

Deprecation timeline for ?wait=true#

  • Day 0–90 from GA: full support, Deprecation: true + Sunset + Link response headers on every ?wait=true response.
  • Day 90–120: advisory window; headers remain.
  • Day 120+: returns 410 Gone with a pointer to the async-polling pattern. Enforced via WAIT_ENFORCE_410 env 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.

FieldAccepted onDefaultNotes
takeawaycreate + appendfalseGenerate 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 via takeaway=true once generation has completed; null otherwise.

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:

FieldNotes
idSession UUID.
statusstreaming | processing | ready | failed. See "Status flow" below.
moderemote. REST metadata describing how the session was created. (Historical sessions may report autonomous; that mode was retired in 2026-05.)
active_modelsModel IDs participating in this session.
moderator_modelNull for all new sessions. Historical sessions may carry a value.
moderator_name, applicationOptional identity metadata.
model_metadata{ [model_id]: { display_name, provider } }.
created_at, estimated_ready_atTimestamps.
total_usageAggregated tokens_in / tokens_out across the session.
total_cost_usdGround-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.
roundsArray of round objects (see below).
summarySession-level editorial. Null until generated for multi-round sessions.
confidence_disclaimerVerbatim advisory string. Surface alongside any displayed confidence scores.
share_urlPublic 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_statusshared (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:

ValueMeaning
completeEvery target model produced a final response.
partial_failureAll target models reached terminal state; at least one final AND at least one errored. Round is usable but degraded.
failedAll target models reached terminal state; every one errored, zero finals. Round produced no usable output.
in_progressAt 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) — true when the response is a successful-but-truncated stream (the model produced output and the call reached done, but the provider signaled truncation via finish_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). null when 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, and is_partial stays false, so branch on finish_reason. Do not retry or rephrase automatically; surface it to the user. A refusal before any output lands in failed_models[] with error_code: "provider_refusal" instead.
  • output_cap ({ tokens, reason } | null) — set when a platform cap bound this call's output length; null otherwise (always present). tokens is the output-token ceiling the cap imposed; reason is guest (the guest output limit) or balance (your remaining credit bounded the output). Pairs with is_partial / finish_reason: a truncated response with a non-null output_cap was cut by the platform cap, not the model's own limit. On balance, top up or wait for the credit reset rather than retrying.

failed_models[] is the error-attribution collection. Each entry:

FieldNotes
modelModel ID that failed.
errorFree-text error description from the row's error column. Stable but not safe to pattern-match — switch on error_code for branching.
messageHuman-readable error message.
error_codeCanonical 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_textOptional. 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_lengthOptional. Character count of partial_text when present.

in_progress_models[] is the "still working" collection — present when completion_state === "in_progress". Each entry:

FieldNotes
modelTarget model ID.
statequeued (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_atISO 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 (typed KEEP/CHALLENGE/etc, with verbatim quotes from peers and optional commentary).

  • claim_map.claims[] — verbatim claims that ≥2 models reacted to, with each reactor's position (type + commentary). The highest-signal artifact for understanding agreement and disagreement. A position can 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 null claim_map is never ambiguous:

    ValueMeaning
    pendingStill being generated, or the responses are still running. Keep polling.
    readyclaim_map has 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_map and round_takeaway stay null.
  • 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:

FieldTypeNotes
promptstringThe question or topic. Required.
referencestringOptional spec, doc, or design injected as shared context.
modelsstring[]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_namestringDisplay name for the steering identity (≤100 chars). Surfaces in the published transcript.
applicationstringDisplay name of your client (≤100 chars). Surfaces in the session info panel.
takeawaybooleanOpt 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_searchbooleanSet 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 same Idempotency-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 includes effective_balance_usd, free_usd, subscription_usd, refill_usd, per_model_minimum_usd, next_reset_at; a positive-balance refusal adds required_usd and affordable_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 includes reason, 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 includes seat_limit, requested_seats, upgrade_url, human_action_required: false. An append cannot change the roster, so start a new deliberation with at most seat_limit models.
  • 413 input_too_long — the new text in this request (the prompt plus your snippets' quotes, comments and quoted_models) is longer than your plan allows. Body includes typed_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 includes unknown_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 noindex and unlisted. status stays shared unless 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_failed whose 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:

  1. Async REST/MCP callers — after a create_deliberation / append_round ack, poll this to learn whether the round reached terminal state (and whether the refund lifecycle moved). The terminal check is: moderation_status is "complete" or "failed", or execution_status is "complete" or "failed". Rounds created from the mumo web UI never write moderation_status (it stays null) — execution_status is 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, read failure_code and the per-model state / error_code entries in models[]. Once terminal, fetch the full content via GET /api/sessions/:id, whose round objects carry failed_models[] for end-of-poll attribution.
  2. 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:

FieldNotes
moderation_statusLegacy round lifecycle: pending | in_progress | complete | failed | null. Stamped and settled by API/MCP-created rounds; always null on web-created rounds.
execution_statusGo-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:

FieldNotes
modelModel ID.
statefinal | error | streaming | queued | absent. Same state machine as in_progress_models[].state plus terminal values.
deadline_atISO 8601 timestamp at which the row will be swept to a terminal error if it hasn't transitioned. null on legacy rows.
error_codeSTREAM_ERROR_CODES value when state === "error". null otherwise.
expired_at_readtrue 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.
providerModel family (anthropic | openai | google | xai | moonshot | zai | alibaba). null when the registry lookup fails.
inference_providerInference 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_lengthCharacter 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_atISO 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_msRead-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.

QueryValues
moderemote (historical sessions may also report autonomous)
statusready | streaming
limit1–200 (default 7)
offsetpagination

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 — true if the caller can actually use this model right now through this API. That means all of: an mmo_live_* key, a plan that includes the model, and credit remaining — the same things /api/deliberation checks before it runs anything. Any other caller (no bearer, or a browser/anonymous session token) gets false on every model, since the execution endpoints reject them regardless of which model they name. false means a call that uses this model will fail.
  • unavailable_reason — why available is false, ordered by what to fix first. "api_key_required" — no mmo_live_* key was presented. "membership_required" — your plan does not include this model; a request naming it is refused with 403 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 when available is 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, and available reports 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 the credit_exhausted body as per_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 (or effective_balance_usd > 0 from GET /api/credit) — both are necessary, not sufficient: a low positive balance can still refuse a particular panel with credit_exhausted. For "what did that cost?" compare effective_balance_usd from GET /api/credit before and after the round; the round's cost_usd reports 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 is credit_exhausted for 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_usd is the platform grant each cycle; resets_at is the next UTC 1st-of-month boundary when the bucket refills.
  • buckets.subscription — Stripe-granted credit (paid tier). rollover_cap_usd is the max unused balance that carries forward into a new cycle. subscription_status is one of "active" | "past_due" | "cancelled" | "expired" | null (null = no subscription).
  • buckets.refill — auto-refill top-ups. auto_refill_enabled is the user's current setting. When enabled, two additional fields appear: auto_refill_threshold_usd (trigger level) and auto_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 explicit pricing_minimum_usd. Reported for reference only; it does not gate admission. For per-model values, read pricing.minimum_usd from GET /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_round or 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.

CodeHTTPRetryableWhat to do
unauthorized401noBad or missing bearer token.
malformed_json400noRequest body was not parseable JSON.
invalid_request400noBody failed schema validation. Body includes issues — the Zod flatten shape, with fieldErrors keyed by field name and formErrors for whole-body problems.
invalid_params400noA path parameter failed validation (e.g. an empty session id). Body includes issues in the same shape.
unknown_models400noOne 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_only400noPOST /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_models400noThe 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_exhausted403noWallet 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_model403noA 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_limit403noThe 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_long413noThe 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.
forbidden403noFeature not available on your account.
terms_acceptance_required403noUpdated 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_unavailable403nomumo 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_suspended403noThe 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_found404noSession 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_conflict409noSame key reused with a different body. Use a new key.
session_busy409yesAnother round is in flight. Retry with the same Idempotency-Key.
wait_sunset410noThe removed ?wait=true synchronous mode. Create the round, then poll GET /api/sessions/:id/progress.
rate_limited429yesToo many appends too quickly. Body includes retry_after_seconds.
daily_limit_reached429yesRound budget exhausted for the period. Body includes resets_at.
internal_error500yesTransient. 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. kind is "drop_most_expensive" (the panel minus its single most expensive model, named in dropped; offered for 3-model panels — pass models to a new POST /api/deliberation) or "cheapest_solo" (the single cheapest model; POST /api/deliberation needs 2–3 models, so this tells you the balance covers one model, not a request this API can run). models are 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/models reports each as unavailable_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: the prompt, plus reference on a create and the snippets' quote, comment and quoted_model on an append. Session history and search results are never counted.
  • upgrade_url — where the account's owner changes plan.
  • human_action_required — true on forbidden_model: only the account's human can change the plan, so an agent should surface it rather than loop. false on panel_seat_limit: send at most seat_limit models (on an append, start a new deliberation, since an append cannot change the roster). false on input_too_long: shorten the text to at most cap.
{
  "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 from text before return.
  • responses[].snippets[].comment_confidence: number | null
  • confidence_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.