# ultrarouter — for agents (quickstart) ultrarouter is an OpenAI/OpenRouter-compatible AI gateway. Anything the OpenAI Chat Completions API does works here — just change the base URL and key. Setting up learning? Start at /docs/get-started: copy-to-agent prompts for Claude Code and Codex, and exact commands in /setup-recipes.json. Learning is a preview; what is and is not demonstrated is in /docs/evidence-status. Building this into a repo? Paste the AGENTS.md snippet at /docs/agents-snippet into your AGENTS.md / CLAUDE.md — one block that onboards ultrarouter (either door), adds the docs MCP, and wires the Bench CI gate. ## Environment - UR_BASE_URL: the gateway base URL, e.g. https://api.ultrarouter.io - UR_API_KEY: an ultrarouter key (ur_sk_live_… or ur_sk_test_…), sent as `Authorization: Bearer $UR_API_KEY`. ## One curl ```bash curl -sS "$UR_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"openai/gpt-5-mini","messages":[{"role":"user","content":"hi"}]}' ``` ## One TypeScript snippet (fetch, zero deps) ```ts const res = await fetch(`${process.env.UR_BASE_URL}/v1/chat/completions`, { method: "POST", headers: { Authorization: `Bearer ${process.env.UR_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "openai/gpt-5-mini", messages: [{ role: "user", content: "hi" }], }), }); const data = await res.json(); console.log(data.choices[0].message.content, data.ur.cost_usd); ``` ## One Python snippet (urllib, stdlib only) ```python import json, os, urllib.request req = urllib.request.Request( f"{os.environ['UR_BASE_URL']}/v1/chat/completions", data=json.dumps({ "model": "openai/gpt-5-mini", "messages": [{"role": "user", "content": "hi"}], }).encode(), headers={ "Authorization": f"Bearer {os.environ['UR_API_KEY']}", "Content-Type": "application/json", }, ) print(json.load(urllib.request.urlopen(req))["ur"]["cost_usd"]) ``` ## Error taxonomy (GW-70) Every /v1 failure returns `{ "error": { "code", "message", "retryable" } }` with one of these stable codes: | code | http status | retryable | | --- | --- | --- | | invalid_request | 400 | no | | manifest_invalid | 400 | no | | manifest_mismatch | 400 | no | | authentication | 401 | no | | permission | 403 | no | | not_found | 404 | no | | model_denied_by_policy | 403 | no | | model_unpriced | 403 | no | | capability_unsupported | 400 | no | | prompt_variables_missing | 400 | no | | conflict | 409 | no | | rate_limited | 429 | yes | | outcome_backlog_full | 429 | yes | | spend_cap_exceeded | 402 | no | | max_cost_exceeded | 402 | no | | atomic_spend_guard_unavailable | 503 | no | | provider_unavailable | 502 | yes | | provider_timeout | 504 | yes | | content_filtered | 400 | no | | context_length | 400 | no | | cancelled | 499 | no | | internal | 500 | no | ## Rate limits (FND-43) - Per API key: 50 requests/second by default (overridable per key). - Auth endpoints: 60 requests/hour per IP. - A throttled request returns 429 with a `Retry-After` header. ## ultrarouter Bench (optional) Turn your own dev/test traffic into a paired benchmark: tag calls with a suite (`X-UR-Bench-Suite`, a **test** key) → freeze an immutable weighted dataset → run your incumbent paired against challengers → read an immutable evidence report (quality + all-in cost intervals, trust class A/B/C, a keep/migrate/inconclusive verdict) → gate CI with `ur bench run --gate`. Start at /docs/bench/quickstart; the honesty rules (dataset cards, power plans, measured vs estimate) are in /docs/bench/concepts; the CLI exit-code contract in /docs/bench/ci-gating. ## ultrarouter Flywheel (optional) Turn the traffic you already send into a data flywheel. Change the base URL and key and ultrarouter reads your calls, then proposes ONE workflow worth learning — your call-type inventory, the outcome channels it detects with their rung, and a plan for the recommended workflow. Nothing is learned until you confirm what "it worked" means: a proposed objective never teaches (it is a type boundary, not a runtime flag). Read the two a-ha moments, the channel ladder and that law in /docs/concepts/discovery; walk it end to end in /docs/guides/discovery-start. --- # Overview source: https://docs.ultrarouter.io/docs (https://docs.ultrarouter.io/docs.md) ultrarouter helps teams **turn the corrections and outcomes their product already generates into tested improvements — and keep that learning when they change models.** It is five capabilities on one ledger and one gate, reached through **two parallel entry actions**. You choose the job; the products sit behind it. ## Two ways in Migration and Flywheel are parallel offers. Start with either — neither is a prerequisite for the other. - **Plan a model migration** — _available today._ Find the replacement that meets your quality bar on your application, with the failures and the rollout plan in front of you. Built on the paired [Bench](/docs/bench) comparison, the qualified [evaluators](/docs/concepts/evals) and the auto-rollback [canary](/docs/concepts/routing). Start from the [migrate guides](/docs/migrate/from-openrouter). A guided one-screen migration workspace is planned. - **Build your data flywheel** — _planned (Phase 10)._ Your reviewers already show what good looks like; put those corrections to work and see whether the next version performs better. The outcome ledger, calibration and the first learned-context lever are being built — this is planned capability, labeled as such, not a shipped feature. ## The ownership promise **"We handle your data moat"** means connecting real outcomes to tested improvements and preserving customer-owned learning across model changes. Your data stays tenant-isolated and fully exportable, and raw items never cross a workspace. We process authorized workspace content to train and evaluate models exclusively for your workspace; we never use it to train shared models or another customer's models. The customer-facing training switch (on by default under a training agreement) and the `train_allowed` enforcement are built; what is not built yet is a governed trainer, so no customer-data training run executes yet — see [the training-ready export](/docs/guides/training-export). A ledger of outcomes is a starting point, not proof on its own — it becomes valuable when its evidence helps the next decision. We do not sell a valuation multiple, a moat certificate, or a guarantee of improvement. ## Five capabilities, one customer journey The first question is your job, not which products to configure. These are the capabilities behind the two jobs above. | Capability | What it is | Availability | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------- | | **[Router](/docs/concepts/routing)** | One OpenAI-compatible URL for 17 providers: BYOK, fallbacks, spend caps, streaming and tools. | Available today | | **[Evals](/docs/concepts/evals)** | Evaluators generated from your traffic, proved to catch defects, gating every change from PR to canary to rollback. | Available today | | **[Scoring](/docs/bench)** | Which model holds your quality bar at what cost per successful task — paired, signed and refusable. | Available today | | **Flywheel** | The outcome ledger: what happened next to every answer, learned into rules and examples, behind a holdout, training-ready. | Planned — Phase 10 | | **Context** | Deterministic context assembly with a manifest: modules, contracts, budgets and SOPs that load lazily. | Planned — Phase 10 | Planned capabilities are labeled throughout. Any improvement claim we publish carries its scope, sample size and confidence interval, or it does not appear. ## What is a drop-in proxy? The available capabilities are OpenAI/OpenRouter-compatible: point your existing OpenAI or OpenRouter SDK at the gateway base URL, keep your `messages`, and you are done. `POST /v1/chat/completions` (streaming + non-streaming), `POST /v1/embeddings`, `GET /v1/models` — the official `openai` client works unmodified; only the base URL and key change. Compression is a [safety taxonomy](/docs/concepts/compression) (off by default), routing is [pinned, multi-offer or auto](/docs/concepts/routing), and the [savings ledger](/docs/concepts/savings) computes honest, procurement-defensible numbers. ## Start here - [Quickstart](/docs/quickstart) — make your first call in five minutes. - [For agents](/docs/for-agents) — the machine-first quickstart (env, curl, TS, Python, error taxonomy, rate limits). - [Concepts](/docs/concepts/call-types) — call types, routing, compression, evals, savings. - [API reference](/docs/api) — every endpoint, generated from the spec. - [Migrate](/docs/migrate/from-openrouter) — from OpenAI direct or OpenRouter. ## Built for agents The whole site is machine-ingestible. Add the documentation MCP server so your coding agent can search the docs, read pages, fetch the OpenAPI spec, and get runnable request examples. It is not on a package registry yet: build it in your ultrarouter checkout and register it from there. [MCP servers for Claude Code and Codex](/docs/guides/mcp-servers) has the Codex form, the installer and the authenticated management MCP. ```bash pnpm --filter "@ultrarouter/docs-mcp..." build claude mcp add --scope user ultrarouter-docs -- node "$PWD/packages/docs-mcp/dist/bin.js" ``` Every page is also available as clean markdown at `.md`, and the whole corpus is at [`/llms-full.txt`](/llms-full.txt) (agent quickstart first). See [For agents](/docs/for-agents) for the full list of surfaces. ## Status The gateway, dashboard, SDKs (TypeScript + Python), compression, evals, canary auto-routing, and savings accounting are all real and documented here — that is the **Router, Evals and Scoring** surface. **Flywheel** is a preview that runs on the local stack, and **Context** is a planned Phase-10 capability; each is labeled wherever it appears. The hosted gateway (`https://api.ultrarouter.io`) is invite-only for now: invited accounts sign in at `https://app.ultrarouter.io` and bring their own provider keys. Learning is not on the hosted service yet, and there is no hosted MCP endpoint. You can also run the stack locally (base URL `http://localhost:8080`) or self-host. Anything not-yet-shipped is marked plainly. --- # The AGENTS.md snippet source: https://docs.ultrarouter.io/docs/agents-snippet (https://docs.ultrarouter.io/docs/agents-snippet.md) Paste the block below into your repo's `AGENTS.md` or `CLAUDE.md`. A coding agent that reads it can onboard ultrarouter, add the docs MCP server, and wire the Bench CI gate without further instructions. Set `$UR_BASE_URL` to your gateway (e.g. `http://localhost:8080`), `$UR_API_KEY` to a `ur_sk_test_…` key, and pick any `` name; everything else is copy-paste-clean. ```md ## ultrarouter — AI cost optimization ultrarouter is an OpenAI-compatible gateway that measures which cheaper model holds up on our real traffic and refuses the ones that don't. ### Onboard (either door is first-class) - Base URL: set the OpenAI client `baseURL` to `$UR_BASE_URL/v1` and the key to `$UR_API_KEY`. Nothing else changes — same request/response shape, plus a `ur` block (request id, model + provider served, routing trail, cost). - SDK: not on npm yet. Build `@ultrarouter/sdk` from github.com/lucastonon/ultrarouter-sdk-typescript (`npm ci && npm run build && npm pack`) and install the tarball; exact commands in `/setup-recipes.json`. Then `new UltraRouter({ apiKey: process.env.UR_API_KEY })`. ### Add the docs MCP (so you can read the API contract yourself) MCP (built from the ultrarouter checkout; not on npm yet): `pnpm --filter "@ultrarouter/docs-mcp..." build`, then `claude mcp add --scope user ultrarouter-docs -- node "$PWD/packages/docs-mcp/dist/bin.js"`. Codex, the installer and the management MCP: [MCP servers](/docs/guides/mcp-servers). Tools: search_docs, get_doc, get_openapi, example_request. ### The out-of-the-box loop 1. Send real dev/test traffic through the gateway — call types appear automatically. 2. Tag a Bench suite on a TEST key: header `X-UR-Bench-Suite: ` (or the SDK `bench: ""` option). A live key is refused — a consent boundary, not a failure. 3. Freeze the suite into an immutable weighted dataset (`POST .../freeze`, optional `max_items`). 4. Run it paired — your incumbent re-invoked against challengers — within a `max_cost_usd` umbrella cap (`POST .../runs`; start with `dry_run: true` and `require_power: true`). 5. Read the verdict — keep / migrate / inconclusive, with quality + cost intervals and a trust class — then gate CI: `ur bench run --gate --baseline bench:main` (exit 0 = pass, 2 = fail/inconclusive). ### Record outcomes (feed the flywheel) After an AI answer is accepted or corrected, POST what happened next: `POST $UR_BASE_URL/v1/outcomes/batch` with `{request_id, channel, kind}` rows (or the SDK `ur.ur.outcomes.record(...)`). Declared outcomes rank and teach; the gateway never invents them. Guide: [Record outcomes](/docs/guides/outcomes). ### Which product? (choose when / not when) Router — any OpenAI-compatible app. Evals — any app shipping prompt/model changes. Scoring — any bill worth a paired test. Flywheel — a checkable outcome or downstream act, not an engagement-only product. Context — apps with more than ~10 SOPs or recurring context bloat. See [Which product?](/docs/for-agents) and [the five products](/docs/concepts/products). ### The honesty rule (tell your human) A `keep` or `inconclusive` verdict is the instrument working, not a failure — never retry a bench until it turns green. Any savings figure is an estimate or a projection until it is reconciled on the ledger. ``` Read next: - [For agents](/docs/for-agents) — the full machine-first surfaces (MCP, `llms.txt`, `.md`, OpenAPI) and the zero-to-verdict REST/SDK recipe. - [Bench quickstart](/docs/bench/quickstart) — the capture → freeze → run → report → gate walk with runnable `curl`. - [Record outcomes](/docs/guides/outcomes) — feed the flywheel after an answer is accepted or corrected. - [Quickstart](/docs/quickstart) — your first chat completion in five minutes. --- # API reference source: https://docs.ultrarouter.io/docs/api (https://docs.ultrarouter.io/docs/api.md) Every endpoint of the ultrarouter API, grouped by surface. These pages are GENERATED from `openapi.yaml` (itself generated from the API route schemas and checked in CI), so they always match the running gateway. Fetch the machine-readable spec at `/openapi.yaml` or `/openapi.json` (CORS-open), or ask the docs MCP server (`get_openapi`, `example_request`). ## Surfaces - [Adaptive optimization](/docs/api/adaptive-optimization) - [API keys](/docs/api/api-keys) - [Audit](/docs/api/audit) - [Bench](/docs/api/bench) - [Billing](/docs/api/billing) - [Call types](/docs/api/call-types) - [Chat](/docs/api/chat) - [Custom providers](/docs/api/custom-providers) - [Embeddings](/docs/api/embeddings) - [Feedback](/docs/api/feedback) - [Flywheel](/docs/api/flywheel) - [Generation](/docs/api/generation) - [Invitations](/docs/api/invitations) - [Keys](/docs/api/keys) - [Knowledge](/docs/api/knowledge) - [Learning](/docs/api/learning) - [Members](/docs/api/members) - [Models](/docs/api/models) - [Ops](/docs/api/ops) - [Passthrough](/docs/api/passthrough) - [Preflight](/docs/api/preflight) - [Provider keys](/docs/api/provider-keys) - [Workspaces](/docs/api/workspaces) --- # Adaptive optimization source: https://docs.ultrarouter.io/docs/api/adaptive-optimization (https://docs.ultrarouter.io/docs/api/adaptive-optimization.md) Adaptive optimization endpoints. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Activate an independently qualified full policy ```http POST /v1/workspaces/{id}/adaptive-rollouts/activate ``` Requires an owner or admin session with two-factor authentication, current explicit Automatic authorization, a valid independent qualification certificate, and a registered serving adapter. Starts at a root-sticky 1% canary. Unavailable prerequisites do not change serving. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/adaptive-rollouts/activate" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "certificateId": "string", "applicationId": "string", "expectedAuthorizationVersion": 0 }' ``` ## Revoke a full-policy rollout ```http POST /v1/workspaces/{id}/adaptive-rollouts/revoke ``` Requires an owner or admin session with two-factor authentication. Future decisions preserve the original request after revocation. The action is audited and idempotent. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/adaptive-rollouts/revoke" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "rolloutId": "string" }' ``` --- # API keys source: https://docs.ultrarouter.io/docs/api/api-keys (https://docs.ultrarouter.io/docs/api/api-keys.md) API-key provisioning. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## List API keys ```http GET /v1/workspaces/{id}/api-keys ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/api-keys" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Create an API key ```http POST /v1/workspaces/{id}/api-keys ``` The raw `ur_sk_*` secret is returned exactly once — never persisted, never logged. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/api-keys" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "mode": "string" }' ``` ## Roll an API key secret ```http POST /v1/workspaces/{id}/api-keys/{keyId}/roll ``` Issues a fresh secret on the SAME key identity; the new secret is shown once. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/api-keys/key_0000000000000000/roll" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Update an API key's default call type ```http PATCH /v1/workspaces/{id}/api-keys/{keyId} ``` Set (a slug/id of an existing call type of this workspace) or clear (null) the key’s DEFAULT call type. A request served by this key that carries no `call_type` (body) and no `X-UR-Call-Type` header is then attributed to it — so a plug-and-play OpenAI-compatible client needs only the base URL + the key. An unknown slug/id is a 404. Bumps config_version (visible ≤ 5 s). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/req_0000000000000000/api-keys/key_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "default_call_type": "string" }' ``` ## Revoke an API key ```http DELETE /v1/workspaces/{id}/api-keys/{keyId} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X DELETE "$UR_BASE_URL/v1/workspaces/req_0000000000000000/api-keys/key_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` --- # Audit source: https://docs.ultrarouter.io/docs/api/audit (https://docs.ultrarouter.io/docs/api/audit.md) Zero-configuration Audit Autopilot — the passive readiness report. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Get the passive Audit Autopilot readiness report ```http GET /v1/audit/readiness ``` The zero-config passive readiness rollup: inferred call types with confidence + evidence coverage, a models/providers inventory, a benchmark-readiness map that names what evidence is unavailable under the current privacy settings, and a DISCOVERY-labeled savings opportunity queue. Read-only — audit has zero authority. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/audit/readiness" \ -H "Authorization: Bearer $UR_API_KEY" ``` --- # Bench source: https://docs.ultrarouter.io/docs/api/bench (https://docs.ultrarouter.io/docs/api/bench.md) ultrarouter Bench — dev/test capture suites + readiness. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Get a bench suite readiness meter ```http GET /v1/bench/suites/{slug}/status ``` Per-call-type confirmed capture counts (provenance) + the QUALIFIED-EVIDENCE readiness — the latest frozen dataset card (unique/cluster n, strata, preregistered power plan). There is NO raw-count `n<30` verdict — readiness is judged on the frozen, qualified evidence. Triggering a run needs the `bench:run` scope; this readiness read needs `config:read`. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/bench/suites/{slug}/status" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Freeze a bench suite into immutable weighted datasets ```http POST /v1/bench/suites/{slug}/freeze ``` Snapshot the suite’s CONFIRMED capture envelopes into immutable, content-addressed, per-call-type WEIGHTED datasets (exact dedupe + near-duplicate clustering, retained frequency, golden-set strata, strict PII admission) and return each dataset CARD (counts, strata, exclusions, preregistered power plan). Re-freezing identical captures is idempotent (same `dataset_hash`). Requires scope `config:write`. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/{slug}/freeze" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "max_items": 0 }' ``` ## Trigger an on-demand paired Bench run ```http POST /v1/bench/suites/{slug}/runs ``` Plan one paired eval per otherwise-runnable (frozen, ≥10-unique) call type over the suite’s FROZEN dataset. `max_cost_usd` is one global umbrella reservation ceiling, never a per-child clamp and never the continuous eval envelope. Live paid dispatch is gated by the `bench.paid_dispatch` kill switch (default ON): when ON, omitting `dry_run` dispatches a real paired run (it reserves the prepaid allowance under the ceiling and makes paid provider calls, each child settling its hold truthfully); when OFF (or on an unseeded DB ⇒ fail-closed) live dispatch is refused before mutation or fan-out with HTTP 503 `atomic_spend_guard_unavailable`. `dry_run: true` always returns the estimate with no mutation and, when the switch is OFF, labels otherwise-valid paid shapes `not_runnable` with that blocker. `selection` mode emits ranked evidence and NEVER a recommendation. DUAL AUTH: the `bench:run` scope OR a session + `bench.write` RBAC (workspace named via `X-UR-Workspace`). Requires API-key scope: bench:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/{slug}/runs" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "max_cost_usd": 0 }' ``` ## Get an on-demand Bench run ```http GET /v1/bench/runs/{id} ``` The umbrella run (status + estimate + allowance + per-call-type dispositions) plus each child eval run’s planning verdict TIER and PER-ARM evidence (incumbent + challengers: composite score, pass flag, arm cost) + the composite TRUST CLASS (A/B/C) and evidence-class tags. Read-only. DUAL AUTH: the `bench:run` scope OR a session + `bench.write` RBAC. Requires API-key scope: bench:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/bench/runs/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Get the immutable Bench evidence report ```http GET /v1/bench/runs/{id}/report ``` The IMMUTABLE, content-addressed evidence report for a completed on-demand Bench run. Renders IN ORDER: (1) the dataset card; (2) the power statement (unique n, planning MDE, the pre-registered margins with snapshot provenance); (3) the headline table per call type × arm — cost views BY BASIS with `$/request` vs `$/task` units honestly labeled, token components as billed units, inversion callouts, latency, quality ± intervals, evidence classes + the PINNED trust class; (4) the dual-interval verdict (or the recommendation-free selection table) with keep/inconclusive first-class + a named remediation — NO realized-savings inference, NO fictitious $0 incumbent; (5) the honest projection ONLY where trailing production volume exists; (6) the per-trace task view; (7) auto-generated limitations; (8) the reproducibility appendix. `?format=json` (default) returns this report; `?format=md` a Markdown export; `?format=bundle` an offline-verifiable evidence bundle (`pnpm evidence:verify`-consumable). All formats are audited (`bench.report.downloaded`). DUAL AUTH: the `bench:run` scope OR a session + `bench.write` RBAC. Requires API-key scope: bench:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/bench/runs/req_0000000000000000/report" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Pin a completed Bench run as the suite baseline ```http POST /v1/bench/runs/{id}/baseline ``` Pin a COMPLETED bench run as the suite’s baseline. The baseline anchors COMPARE mode: it captures the run’s immutable RunSpec + RETAINED arm artifacts (never a mutable config pointer). Re-pinning advances the baseline; audited (`bench.baseline.pinned`). Fails closed on an unknown / wrong-suite / not-complete run. DUAL AUTH: the `bench:run` scope OR a session + `bench.write` RBAC. Requires API-key scope: bench:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/runs/req_0000000000000000/baseline" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Get a compare run’s baseline-compare gate ```http GET /v1/bench/runs/{id}/gate ``` The interval-based tri-state verdict for a COMPARE run. Pairs the candidate’s (current-config) per-item results against the pinned BASELINE run’s RETAINED artifacts (real retained outputs, never re-invoked) BY CONTENT HASH, then yields `pass | fail | inconclusive` per call type + an INDEPENDENT absolute measured-cost ceiling check. `pass` requires BOTH the quality non-inferiority interval AND the cost-savings interval to clear their registered bars; a ceiling breach FAILS independent of quality; a missing/expired baseline artifact makes the pair ineligible and degrades toward `inconclusive` (never silently green). MDE is a POWER DIAGNOSTIC only. The umbrella verdict is the STRICTEST across call types. DUAL AUTH: the `bench:run` scope OR a session + `bench.write` RBAC. Requires API-key scope: bench:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/bench/runs/req_0000000000000000/gate" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Mint a shareable public bench-report link ```http POST /v1/bench/runs/{id}/share ``` Mint an IMMUTABLE, REVOCABLE, unguessable-token PUBLIC render of a completed bench run’s report SNAPSHOT (the product’s first UNAUTHENTICATED tenant-data surface). The public payload is BUILT AT MINT TIME from the immutable evidence report and frozen — a later report/config change NEVER changes a minted share. It carries aggregate metrics, model names, methodology/limitations, trust class and the evidence-contribution footer — never workspace identity beyond the chosen `display_name`, never un-redacted content, never id/key material. REDACTED example previews appear ONLY when the suite carries the audited attestation AND `include_previews` is set (opt-in never bypasses redaction). The raw token is returned exactly once (only its SHA-256 hash is stored); the public read is by token at `/share/bench/`. The "Benchmarked with ultrarouter" footer renders on free and is removable only on Team. SESSION + owner|admin RBAC (`bench.share`) only — there is deliberately no api-key scope. Audited. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/runs/req_0000000000000000/share" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "display_name": "string" }' ``` ## List a bench run’s public shares ```http GET /v1/bench/runs/{id}/shares ``` The run’s public shares for the revoke list: metadata + coarse view counters, NEVER the token (a bearer secret shown once at mint). SESSION + owner|admin RBAC (`bench.share`). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/bench/runs/req_0000000000000000/shares" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Revoke a public bench-report share ```http DELETE /v1/bench/runs/{id}/share/{shareId} ``` Revoke a minted share. A revoked share becomes a public 404 (indistinguishable from unknown), effective ≤ one public-cache TTL window. SESSION + owner|admin RBAC (`bench.share`). Audited (`bench_share.revoked`). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X DELETE "$UR_BASE_URL/v1/bench/runs/req_0000000000000000/share/{shareId}" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Attach customer assertions to a bench suite ```http POST /v1/bench/suites/{slug}/expectations ``` Attach (`ur.bench.expect`) one or more customer ASSERTIONS mapped onto the CLOSED programmatic scorer vocabulary (json_schema / exact_field / regex_match / citation_substring). Assertions are validated (an unknown kind or malformed config is rejected), versioned and append-only; at FREEZE the applicable ones are MERGED into the call type’s compiled evaluator suite as tier-1-class evidence and the merged suite re-passes mutation qualification (a vacuous/contradictory assertion is rejected with a visible reason). They NEVER mutate a frozen dataset — they attach FORWARD. DUAL AUTH: the `bench:run` scope OR a session + `bench.write` RBAC. Requires API-key scope: bench:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/{slug}/expectations" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "expectations": [ { "kind": "string" } ] }' ``` ## Record per-trace outcomes on a bench suite ```http POST /v1/bench/suites/{slug}/outcomes ``` Record (`ur.bench.record`) first-party per-trace binary OUTCOMES (pass/fail + optional labels) as the strongest human/outcome evidence class (above the LLM judge). Outcomes are scored via the McNemar/binary machinery on paired arms and feed the trace-clustered decision; they are append-only (a superseding verdict wins by version) and attach FORWARD (the next run consumes them). DUAL AUTH: the `bench:run` scope OR a session + `bench.write` RBAC. Requires API-key scope: bench:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/{slug}/outcomes" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "outcomes": [ { "trace": "string", "passed": true } ] }' ``` ## Record per-trace outcomes on a bench run ```http POST /v1/bench/runs/{id}/outcomes ``` Record (`ur.bench.record`) first-party per-trace binary OUTCOMES scoped to a specific run (its suite is resolved from the run). Same McNemar/binary + trace-cluster semantics as the suite route; append-only + append-forward. DUAL AUTH: the `bench:run` scope OR a session + `bench.write` RBAC. Requires API-key scope: bench:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/runs/req_0000000000000000/outcomes" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "outcomes": [ { "trace": "string", "passed": true } ] }' ``` --- # Billing source: https://docs.ultrarouter.io/docs/api/billing (https://docs.ultrarouter.io/docs/api/billing.md) Billing endpoints. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Read real credit and noncash evaluation balances ```http GET /v1/workspaces/{id}/billing/account ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/billing/account" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Choose UR credits or BYOK explicitly ```http POST /v1/workspaces/{id}/billing/funding ``` Changes authorization only; does not fund credits. Platform fallback from BYOK requires explicit permission. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/billing/funding" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "mode": "ur_credits", "allowPlatformFallback": true, "requestKey": "string" }' ``` ## Purchase prepaid inference credits ```http POST /v1/workspaces/{id}/billing/credit-checkout ``` Minimum credit purchase is $5. A 5.5% service fee is added once at checkout, rounded to cents. $5 credits costs $5.28. Credits are funded only by verified payment settlement, never a return URL. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/billing/credit-checkout" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "creditCents": 0, "requestKey": "string" }' ``` ## Start a $49 monthly Pro subscription ```http POST /v1/workspaces/{id}/billing/pro-checkout ``` Includes $5 monthly noncash evaluation allowance. Duplicate pending/active purchases are refused. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/billing/pro-checkout" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "requestKey": "string" }' ``` ## Cancel Pro at the paid period end ```http POST /v1/workspaces/{id}/billing/pro-cancel ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/billing/pro-cancel" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{}' ``` --- # Call types source: https://docs.ultrarouter.io/docs/api/call-types (https://docs.ultrarouter.io/docs/api/call-types.md) Programmatic call-type config (the objects the dashboard edits). This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## List call types ```http GET /v1/call-types ``` Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Create a call type ```http POST /v1/call-types ``` Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "string" }' ``` ## Get a call type ```http GET /v1/call-types/{id} ``` Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Update a call type config ```http PATCH /v1/call-types/{id} ``` Writes a new immutable config version + repoints + bumps `config_version`. A partial update: a field you omit keeps its current value, and an explicit `null` clears it — so a body that only sets `learned_context_delivery` or `self_improve_mode` never changes the pin, router mode, fallbacks or provider preferences. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/call-types/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model_pin": "string", "router_mode": "pinned" }' ``` ## List eval runs ```http GET /v1/call-types/{id}/evals ``` A call type’s eval runs, their candidate summaries, and each run’s immutable RunSpec (the pinned ExecutionProfile components). Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/evals" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Enqueue a manual eval run ```http POST /v1/call-types/{id}/evals ``` Kicks an `eval.run` job for this call type. An optional body selects the runner mode (`request` default, or `task` for the whole-trace $/task runner). Returns 202 with the enqueued run, or 200 with a documented no-op — a dedupe (`deduped:true`) also names the prior run it deduped onto (`prior_run_id` + its `trigger`/`status`), so a client can follow it. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/evals" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "request" }' ``` ## List routing recommendations ```http GET /v1/call-types/{id}/recommendations ``` Eval-backed model-migration recommendations for this call type. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/recommendations" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## List hosted prompt versions ```http GET /v1/call-types/{id}/prompts ``` The call type’s hosted prompt versions (newest first) + the active pointer. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/prompts" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Create a hosted prompt version ```http POST /v1/call-types/{id}/prompts ``` Append a new (immutable) hosted prompt version for this call type. The version is INACTIVE until activated; activating an older version is the rollback. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/prompts" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template": "string" }' ``` ## Get the host-your-prompt onboarding proposal ```http GET /v1/call-types/{id}/prompts/proposal ``` The "host your prompt" onboarding proposal for a CODE-OWNED call type: v1 is the MOST COMMON leading system message in the call type’s CONSENTED captured traffic (no body is read without consent). Accepting it is the ordinary create + activate with gateway_apply:false — nothing is applied silently. `available:false` carries a NAMED reason. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/prompts/proposal" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Get a hosted prompt version ```http GET /v1/call-types/{id}/prompts/{version} ``` Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/prompts/{version}" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Activate a hosted prompt version ```http POST /v1/call-types/{id}/prompts/{version}/activate ``` Make this version the call type’s ACTIVE hosted prompt (writes a new config version + bumps `config_version`; visible to the gateway within one 5 s config window). Activating an OLDER version is the rollback. `gateway_apply` makes the proxy path render + substitute it as the leading system instruction. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/prompts/{version}/activate" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "gateway_apply": true }' ``` ## Deactivate the hosted prompt ```http POST /v1/call-types/{id}/prompts/deactivate ``` Back to CODE-OWNED (the request’s own messages pass through untouched). Writes a new config version with the hosted-prompt pointer cleared. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/prompts/deactivate" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Get the verified-memory status ```http GET /v1/call-types/{id}/verified-memory ``` The frozen verified-memory setting on the call type’s ACTIVE config version (rule version, thresholds, pool hash, the calibration + pool accounting), the workspace’s lab authorisation, and a LIVE servability check of the pinned pool (consent, revocation / dispute tombstones, the per-snapshot item budget — `over_budget` — and the pool hash). A stored setting the gateway cannot load is reported `enabled:true`, `setting:null`, `servable.reason = setting_unreadable` — never shown as off. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/verified-memory" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Enable verified memory ```http POST /v1/call-types/{id}/verified-memory/enable ``` A LAB-AUTHORISED pre-evidence lever (staff grant `flywheel.verified_memory_lab`; no customer route grants it — 403 without it). Builds the call type’s pool from its FULL admitted, non-holdout verified outcomes, calibrates the confidence rule (similarity + top-k agreement) by grouped cross-fitting against the DECLARED target precision, and — only when an operating point clears it — persists the pool and appends a config version carrying the frozen setting (config_version bumped: served within one 5 s window). The pool holds at most 250 items (the newest), and the workspace’s memory call types together at most 250 — the budget one config snapshot re-loads on every 5 s refresh (a gateway-overhead bound); past it the enable is refused `snapshot_budget_exceeded`. The frozen setting is re-parsed from its own stored form before any config version carries it: a setting the gateway could not load is refused `setting_unreadable`, so an enable that succeeds always serves. An evidence refusal (`enabled:false` + a NAMED reason + the pool / calibration read) writes no config version. Re-enabling re-derives the pool (e.g. after a revoked or disputed item tombstoned it). Once enabled, memory answers only a request whose meaning its key captures (one instruction message + one text user turn; no image / audio part, earlier turn, extra context message, prompt variables or unsupported parameter) and whose `response_format`, `stop` and `max_tokens` the verbatim answer provably honours — every other request reaches the model, the reason named on its request row. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/verified-memory/enable" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "target_precision": 0 }' ``` ## Disable verified memory (kill switch) ```http POST /v1/call-types/{id}/verified-memory/disable ``` The KILL SWITCH / rollback: appends a config version with memory OFF (config_version bumped), so every request of the call type reaches the model again within one 5 s window. Never gated on the lab authorisation; idempotent (`changed:false` when already off). Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/verified-memory/disable" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Get the episode-memory status ```http GET /v1/call-types/{id}/episode-memory ``` The frozen episode-memory setting on the call type’s ACTIVE config version (k, char budgets, the pinned/live memory instant, an optional source call type, the record switch), the workspace’s lab authorisation, the episode-trace accounting (total / pass / fail / unverified + the latest pass verification), and a content-addressed PREVIEW of the memory the setting would serve now (`memory.digest` = `emd1_…` over the ordered eligible set, `memory.verified_pass` its size). The optional `?as_of=` overrides the pin FOR THIS READ ONLY, so a checkpoint instant can be digested before the call type is pinned to it. The preview is consent-gated (empty without consent). Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/episode-memory" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Enable episode memory ```http POST /v1/call-types/{id}/episode-memory/enable ``` A LAB-AUTHORISED pre-evidence lever (staff grant `flywheel.episode_memory_lab`; no customer route grants it — 403 without it). Freezes a setting on a new config version (config_version bumped: served within one 5 s window), so the gateway records this call type’s agent episodes and injects the most similar VERIFIED past episodes (by task) into EVERY step of later episodes. Invalid knobs are a 400 (the named reason); an unknown `source_call_type_id` is a 404. No client-side code, no special headers. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/episode-memory/enable" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "k": 0, "max_chars_per_episode": 0 }' ``` ## Disable episode memory (kill switch) ```http POST /v1/call-types/{id}/episode-memory/disable ``` The KILL SWITCH / rollback: appends a config version with episode memory OFF (config_version bumped), so the call type serves model-only again within one 5 s window. Never gated on the lab authorisation; idempotent (`changed:false` when already off). Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/episode-memory/disable" \ -H "Authorization: Bearer $UR_API_KEY" ``` --- # Chat source: https://docs.ultrarouter.io/docs/api/chat (https://docs.ultrarouter.io/docs/api/chat.md) OpenAI-compatible chat completions (streaming + non-streaming). This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Create a chat completion ```http POST /v1/chat/completions ``` OpenAI-compatible chat completions. Set `stream:true` for OpenAI-shaped SSE: a `text/event-stream` of `chat.completion.chunk` events, a consolidated final chunk carrying `usage` + the `ur` block, then `data: [DONE]`. Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "role": "system" } ] }' ``` --- # Custom providers source: https://docs.ultrarouter.io/docs/api/custom-providers (https://docs.ultrarouter.io/docs/api/custom-providers.md) Workspace-owned OpenAI-compatible providers. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## List custom providers ```http GET /v1/workspaces/{id}/providers ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/providers" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Register a custom provider ```http POST /v1/workspaces/{id}/providers ``` The base URL is SSRF-validated on save. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/providers" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "base_url": "string" }' ``` ## Get a custom provider ```http GET /v1/workspaces/{id}/providers/{providerId} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/providers/prov_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Update a custom provider ```http PATCH /v1/workspaces/{id}/providers/{providerId} ``` A changed base URL is re-validated through the SSRF guard. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/req_0000000000000000/providers/prov_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "base_url": "string" }' ``` ## Delete a custom provider ```http DELETE /v1/workspaces/{id}/providers/{providerId} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X DELETE "$UR_BASE_URL/v1/workspaces/req_0000000000000000/providers/prov_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` --- # Embeddings source: https://docs.ultrarouter.io/docs/api/embeddings (https://docs.ultrarouter.io/docs/api/embeddings.md) Multi-provider embeddings. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Create embeddings ```http POST /v1/embeddings ``` Multi-provider embeddings over the OpenAI Embeddings shape. Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/embeddings" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "string", "input": "string" }' ``` --- # Feedback source: https://docs.ultrarouter.io/docs/api/feedback (https://docs.ultrarouter.io/docs/api/feedback.md) Human feedback that feeds evals. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Submit feedback on a request ```http POST /v1/requests/{id}/feedback ``` Thumbs up/down + tags + optional comment, scoped to the key’s workspace. Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/requests/req_0000000000000000/feedback" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rating": 1 }' ``` --- # Flywheel source: https://docs.ultrarouter.io/docs/api/flywheel (https://docs.ultrarouter.io/docs/api/flywheel.md) The outcome ledger — artifacts, typed outcome events, finals, pending claims. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Register an artifact type / append an objective version ```http POST /v1/artifact-types ``` Declare what “it worked” MEANS for a type of artifact (the ONE thing the product refuses to infer), so every later artifact of that type pins this objective version. Objective versions are APPEND-ONLY: an upsert whose `archetype` + `objective` are unchanged is an idempotent NO-OP returning the EXISTING version; any change APPENDS version `max+1`. Never an UPDATE, never a rewrite. NEVER a body — `resolution_criteria` is redacted free text. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/artifact-types" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "archetype": "forecast", "objective": { "metric": "claim_resolution", "unit": "string", "direction": "string", "resolution_criteria": "string", "source": "string", "window": "string" } }' ``` ## List artifacts (metadata only) ```http GET /v1/artifacts ``` Keyset-paginated artifact metadata (never OFFSET). Filter by archetype/key. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/artifacts" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Create or upsert an artifact by key ```http POST /v1/artifacts ``` Create/upsert the customer’s unit of value by `key` (archetype, artifact_type, occurred_at, request_ids[], declared claims[], situation facets). A request already grouped into ANOTHER artifact is a `conflict` (409) with the owning artifact id. Declared claims always win their claim_key: a key the platform extractor already owns is SUPERSEDED — the extracted claim is kept under `superseded_claims` (state `superseded`, linked by `superseded_by_claim_id`) and your claim becomes the active one; re-declaring a key you already declared keeps the first declaration. NEVER a body. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/artifacts" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "string" }' ``` ## Get an artifact (metadata only) ```http GET /v1/artifacts/{id} ``` One artifact + its claims (cached reducer state) + active request memberships. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/artifacts/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Record 1..N outcome events on an artifact ```http POST /v1/artifacts/{id}/outcomes ``` Record 1..N typed outcome events for the artifact or a claim (by `claim_id`/`claim_key`). Provenance (`source`/`producer`), `latency_class` and `learning_use` are SERVER-assigned; a caller-supplied value is ignored. Idempotent on `(producer, target, kind, source_event_id)`: an identical replay is `duplicate`, a same-key different payload is `conflict`. A `claim_key` targets the key’s ACTIVE claim; a NEW event addressed by `claim_id` to a claim a declared claim superseded is `rejected` (`claim_superseded: …`, naming the active claim) — a replay of an event recorded before the supersession stays `duplicate`. 202 only after the durable insert. NEVER a body. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/artifacts/req_0000000000000000/outcomes" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "outcomes": [ { "channel": "mechanical", "kind": "string" } ] }' ``` ## Record the human-approved final output (channel 2) ```http POST /v1/artifacts/{id}/final ``` Outcome channel 2 — the ONLY route that may carry a body, and ONLY under `corrections_consent`: the final is stored in the workspace’s encrypted evidence blob store and `diff_stats` are computed server-side against the draft. WITHOUT consent the caller sends CLIENT-computed `diff_stats` + `final_hash` (the SDK `diffStats` helper) and the row is `stored:"metadata_only"`, `client_computed:true`. A body without consent is rejected (400). Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/artifacts/req_0000000000000000/final" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "artifact_version": 0, "final": "string" }' ``` ## Record ≤ 1,000 outcome events in a batch ```http POST /v1/outcomes/batch ``` Record ≤ 1,000 events keyed by `request_id | artifact_id | artifact_key` (+ optional `claim_key`) with per-row acceptance (`accepted | duplicate | conflict | rejected{reason} | pending`) + a verdict rollup. Same idempotency + server-assigned provenance as the single routes. A `request_id` row follows the request-outcome contract: when the request has not been recorded yet it is `pending` with a durable `receipt` (applied when the request lands), every such row carries its `receipt` (the same one the request-outcome route returns for the same `source_event_id`), and a refusal is in-row (`rejected` with `request_not_found` / `outcome_backlog_full`). NEVER a body. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/outcomes/batch" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "outcomes": [ { "channel": "mechanical", "kind": "string" } ] }' ``` ## List claims awaiting resolution ```http GET /v1/outcomes/pending ``` Keyset-paginated claims awaiting resolution (`state=pending|overdue`). A claim past its `resolves_by` with no resolution reads as `overdue` (never voided). Filter by `since`/`archetype`. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/outcomes/pending" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Record an outcome via the request shortcut ```http POST /v1/requests/{id}/outcomes ``` Record ONE outcome for a request: it lands on the request’s existing artifact, else a one-request `answer` artifact is created. Replay identity SURVIVES regrouping (the `(producer, request_id, kind, source_event_id)` binding pins the original target/version). Report it the moment you hold the final response: when the request has not been recorded yet the answer is still 202, with `status: "pending"` and a durable `receipt` that is applied once the request lands (or expires `request_not_found` after 15 minutes). Every 202 carries the `receipt`; a retry with the same `source_event_id` and payload returns the same receipt and never counts twice. 400 invalid id or outcome, 404 a gateway-minted `req_…` id too old (or too far ahead) to still be in flight, 409 the same key with a different payload, 429 `outcome_backlog_full` when your workspace already holds 5,000 pending receipts. NEVER a body. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/requests/req_0000000000000000/outcomes" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "mechanical", "kind": "string" }' ``` ## List the outcome receipts for a request ```http GET /v1/requests/{id}/outcome-receipts ``` Every outcome receipt for one request in your workspace, oldest first — `pending` until the request is recorded, then `applied` / `rejected` / `failed` / `expired`, with the episode association. A request you reported nothing for (or one of another workspace) is an empty list. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/requests/req_0000000000000000/outcome-receipts" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Read one outcome receipt ```http GET /v1/outcome-receipts/{id} ``` One receipt (`ocr_…`): `status`, `reason`, the episode association, `channel`, `kind`, timestamps and the recorded `outcome_event_id`. 404 when it does not exist in your workspace. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/outcome-receipts/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Update the workspace Flywheel consents ```http PATCH /v1/workspaces/{id}/flywheel-consents ``` Owner-only, audited, bumps `config_version`: `corrections_consent` (store finals), `training_export_consent` (allow the training-READY export), `corrections_retention_days`. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/req_0000000000000000/flywheel-consents" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "corrections_consent": true, "training_export_consent": true }' ``` ## Read the workspace self-learning status ```http GET /v1/workspaces/{id}/self-learning ``` The effective self-learning state: knowledge + corrections consent, the retention mode (a warning when `none`, so episode memory cannot record), whether episode memory is served by default for the workspace, and every call type’s per-call-type source (`explicit` / `default` / `off`). Any member. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/self-learning" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Set the one-step self-learning opt-in ```http PUT /v1/workspaces/{id}/self-learning ``` The ONE-STEP owner opt-in that makes episode memory plug-and-play. ON turns on knowledge consent (a new optimization-authorization version preserving every other field — or the manual-routing / byok onboarding defaults when none exists) AND corrections consent, so every call type with no explicit setting learns by default. OFF revokes both (which hard-deletes the workspace’s episode traces). owner|admin, audited, bumps `config_version`; idempotent. This never flips the platform default-on switch. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PUT "$UR_BASE_URL/v1/workspaces/req_0000000000000000/self-learning" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` ## Read the workspace training-authorisation status ```http GET /v1/workspaces/{id}/training ``` The training AUTHORISATION status: the two switches (customer `training_enabled` + the superadmin-only platform switch), the pure six-conjunct `train_allowed` verdict and the conjuncts that FAILED (fail-closed), and the authorisation epoch. Training is allowed ONLY per-workspace, behind the gate; `training_export_consent` is NOT a conjunct (enabling the export never authorises training). RBAC `training.read` (any member). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/training" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Set the per-workspace training switch ```http PATCH /v1/workspaces/{id}/training ``` Flip the CUSTOMER-VISIBLE per-workspace training switch (the `workspace_enabled` conjunct). owner|admin, audited, bumps `config_version`. Turning it OFF invalidates the authorisation epoch: serving of this workspace's adapters stops and active jobs are cancelled within one 5 s window, and a stale provider callback restores nothing (even after re-enabling). A superadmin can NEVER reach this action, so a customer OFF is never overridable. RBAC `training.write`. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/req_0000000000000000/training" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` ## The workspace site-brief status ```http GET /v1/workspaces/{id}/site-brief ``` The newest site-brief status: status, source, the fetched URL, the content hash + retrieval date, the redacted text length, and the per-category redaction counts. NEVER the page text (it is read server-side only). RBAC `flywheel.read`. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/site-brief" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Request the site-brief fetch (hybrid onboarding) ```http PUT /v1/workspaces/{id}/site-brief ``` Request a fetch of the customer’s public website/docs (hybrid onboarding’s first source). Give an explicit `url`, or omit it to INFER `https://` (only for a non-public-mailbox domain). The URL is validated through the ONE shared SSRF guard synchronously (a private/loopback host is a 400) before a `site_brief.fetch` job is enqueued. RBAC `flywheel.write`; audited. The brief is stored REDACTED bytes read by no consumer yet — never a page body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PUT "$UR_BASE_URL/v1/workspaces/req_0000000000000000/site-brief" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "url": "string" }' ``` ## Mint an exposure grant for a non-proxied call ```http POST /v1/flywheel/grants ``` The deterministic derivation of the SAME `assignExposure` the gateway stamps at dispatch, minted by the sanctioned owner so a NON-proxied call (the SDK/preflight) can join the treated/holdout arm and have its lift measured. Body: `{ call_type?, artifact_key | trace_id }` (one of artifact_key/trace_id REQUIRED). Idempotent: the SAME key in the SAME epoch returns the SAME grant and writes NO second `requests` row. Writes a zero-cost `status=preflight` served record so lift denominators + `/v1/generation` see it. Auth: an api key with `outcomes:write`, OR a Better Auth session + `flywheel.write` RBAC. NEVER cached; NEVER a body. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/grants" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "call_type": "string", "artifact_key": "string" }' ``` ## Fix with AI — revise a review-queue draft (billed as the customer’s own usage) ```http POST /v1/flywheel/review/assist ``` The review queue’s "Fix with AI": the server reads the FULL draft of `item_id` (consent + retention gated, audited), composes a prompt from the draft + the reviewer’s optional `note` + optional `reject_reason`, and dispatches ONE chat completion through the SAME metered gateway path as `/v1/chat/completions` — call type `ur_review_assist`, the model fixed by the server — so it is the customer’s OWN usage (a `requests` row + `gateway_call` ledger row + the workspace spend caps all apply). The returned `revision` fills the editor for the human to accept / change / discard — it is NEVER stored as a Final or an outcome by itself. A workspace spend-cap breach is a named 402. OWNER/member SESSION only (`flywheel.write`), never an api key; audited (`flywheel.review.assist`, ids / hashes only). The session path names its workspace via the `X-UR-Workspace` header. NEVER a body in a log. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/review/assist" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "item_id": "string" }' ``` ## Flywheel status — capture counts + integration health ```http GET /v1/flywheel/status ``` The four SEPARATED usage quantities (operational capture counters vs the PAID analysis units + evaluation compute, never merged), integration health (channels detected + history-import summary), with the LEARNING fields (confirmation/class/calibration) explicitly UNAVAILABLE on this read. Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC (the session path names its workspace via the `X-UR-Workspace` header). NEVER a body. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/status" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## What your moat needs next — per-unit readiness + the missing precondition ```http GET /v1/flywheel/moat-next ``` The meta-scheduler status projection. Per scheduling unit (call type × lever family × situation class): whether it is ready to improve and its scheduling verdict, or WHICH readiness precondition is missing, in the customer’s terms ("this call type needs 140 more resolved claims in this situation class"). A bare `false` is never returned — `next_action` is always a sentence. Reads the LATEST scheduling decision per unit. Optional `lever_family` filter. Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC. NEVER a body. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/moat-next" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## List the workspace bounded experiments ```http GET /v1/flywheel/experiments ``` LIST the workspace bounded experiments, newest first — ids / hashes / state / admission receipt / budget / expiry only (never a body). OWNER SESSION only (`flywheel.experiment`). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/experiments" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Declare a bounded, preregistered experiment on a unit below the protected floor ```http POST /v1/flywheel/experiments ``` Declare ONE bounded EXPERIMENT on a unit (call type × situation × lever) whose history is below the protected benchmarkable floor. The platform admits it ONLY by its own FROZEN rule — the readiness unmet ONLY on the lifecycle floor + the evaluator qualification, and the imported history reproduced by the frozen workflow verifier from its receipts. Declaring it changes nothing in serving: nothing is produced, injected, served, adopted or spent; the floor is never changed and the unit is never labelled benchmarkable. The row lands `admitted` or `refused` (the owner receipt names why). OWNER SESSION only (`flywheel.experiment`), never an api key; audited. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "call_type_id": "string", "preregistration": { "verifier_version": "string", "budget_cap_micro_usd": 0, "screen_subcap_micro_usd": 0, "expiry_days": 0, "fit_families": [ "string" ], "screen_families": [ "string" ], "test_families": [ "string" ] } }' ``` ## Read one bounded experiment ```http GET /v1/flywheel/experiments/{id} ``` Read ONE bounded experiment by id — the frozen preregistration hash, the verifier version, the admission receipt, the budget caps + spend, the state and the expiry. OWNER SESSION only (`flywheel.experiment`). NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Re-affirm an admitted experiment against the current evidence ```http POST /v1/flywheel/experiments/{id}/admit ``` RE-AFFIRM an `admitted` experiment: re-run the admission against the CURRENT readiness + the verifier. If it no longer holds (consent revoked, the verifier now refuses) it transitions `admitted → refused`; otherwise it stays admitted (idempotent). The frozen create-time admission receipt is NEVER rewritten. OWNER SESSION only (`flywheel.experiment`); audited. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/admit" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Launch the one structured-candidate production attempt ```http POST /v1/flywheel/experiments/{id}/produce ``` LAUNCH the ONE structured-candidate production attempt for an admitted experiment: the worker reads the FROZEN fit split, optionally runs ONE outcome-blind teacher call (reserved + settled through the experiment budget), and freezes AT MOST ONE candidate (at most two first-draft observations) or ABSTAINS by name. One attempt per experiment (a claim prevents a second). Read the candidate back on `GET /v1/flywheel/experiments/{id}` (`candidate`). OWNER SESSION only (`flywheel.experiment`); audited (ids/hashes/counts — never the pack text). NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/produce" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Enrol one execution slot of a candidate-frozen experiment (the gateway decides the arm) ```http POST /v1/flywheel/experiments/{id}/executions ``` ENROL one execution slot of an experiment whose candidate is frozen: name the `phase`, the `pair_key` (one idea family frozen in the preregistration’s screen or test split) and the `slot` (0 or 1). The gateway — never the caller — decides the ARM from the experiment’s sealed assignment: a keyed hash of the frozen preregistration hash and the pair picks which slot is treated, so the two slots of a pair always get opposite arms. A repeat of the same `{phase, pair_key, slot}` returns the SAME execution (`created: false`). Send the returned `execution_id` as the `X-UR-Experiment-Execution` header on EVERY call of the execution (on `POST /v1/messages` and `POST /v1/responses`). Refusals are named in the error message: `experiment_not_live`, `candidate_not_frozen`, `phase_not_open`, `phase_unfunded`, `pair_not_in_manifest`, `slot_invalid` (and `experiment_not_found`). Auth: an api key with `flywheel:experiment:run` (never implied by `gateway:invoke`), OR the owner session (`flywheel.experiment`). Audited on creation (ids only). NEVER a body. Requires API-key scope: flywheel:experiment:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/executions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phase": "screen", "pair_key": "string", "slot": 0 }' ``` ## Record the one terminal outcome of an enrolled execution (no bodies) ```http POST /v1/flywheel/experiments/{id}/executions/{execution_id}/outcome ``` RECORD the ONE terminal outcome of an enrolled execution: `terminal` (delivered | held), its typed `reason_codes` under the frozen `reason_vocab`, one body-free `receipts[]` entry per member call (the workflow-import receipt shape, stamped with its `extractor_version`), the `final_content_hash` and EVERY gateway request id the execution made (`request_ids`). NO bodies. Idempotent on the execution: the same payload converges (`created: false`); a different one is refused `outcome_conflict`. The platform validates it against the experiment’s FROZEN verifier and its OWN rows, refusing by name: `request_not_bound` (a listed request is not a call of this execution), `membership_incomplete` (a call of the execution is not listed), `receipt_missing:` (a successful member call — a writer, review, stranger read or repair that returned a 2xx — has no receipt; only failed attempts may lack one), `verifier_mismatch:` (the receipts do not reproduce the recorded terminal; codes the verifier cannot reconstruct are returned as `trusted_recorded`), `receipt_extractor_mismatch`, `receipt_request_mismatch`, `reason_vocab_mismatch`, `reason_code_unregistered` (and `execution_unknown`, `experiment_not_live`, `phase_not_open`). An outcome posted before the last call’s row lands is accepted as `pending_rows` and completes when the rows land (an outcome that turns invalid when they land makes the screen incomplete for good). Each execution’s cost is reconciled (measured rows vs settled, ambiguous and reconciled holds); an open, pending, unpriced or unexplained one is incomplete. Recording an outcome re-evaluates the screen: the verdict is computed exactly once, only when every preregistered screen pair is complete and no hold of the phase is reserved or ambiguous. With `?dry_run=true` the SAME validation runs against the current rows and holds and its answer is returned (`status`, the named `refusal`, `missing_request_ids`, `unlisted_request_ids` — the execution’s own calls the payload does not list — and `open_holds`) with NOTHING written: no outcome, no state change, no audit; on an execution with a recorded outcome it reports whether the payload would converge or conflict. A live experiment outcome is never an imported row. Auth: an api key with `flywheel:experiment:run`, OR the owner session (`flywheel.experiment`). Audited (ids / hashes only). Requires API-key scope: flywheel:experiment:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/executions/{execution_id}/outcome" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "terminal": "delivered", "reason_codes": [ "string" ], "reason_vocab": "string", "receipts": [ { "ordinal": 0, "stage": "string", "call_type": "string", "request_id": "string", "extractor_version": "string", "numbers": {} } ], "final_content_hash": "string", "request_ids": [ "string" ] }' ``` ## Evaluate the screen now (idempotent) and read it ```http POST /v1/flywheel/experiments/{id}/screen/evaluate ``` EVALUATE the screen explicitly — the same exactly-once evaluation a recorded outcome, judgement or hold reconciliation runs — so facts that changed since (a call’s row landing, a hold settling) are picked up. When the completion barrier is met the verdict is computed and stored ONCE (concurrent calls make one verdict and one audit event; `computed` is true only for that call); otherwise nothing is written. Returns the screen read: counts only before the verdict, the verdict after it. Idempotent; reads no body. Auth: an api key with `flywheel:experiment:run`, OR the owner session (`flywheel.experiment`). Requires API-key scope: flywheel:experiment:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/screen/evaluate" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## List an experiment’s holds (the money each call reserved) ```http GET /v1/flywheel/experiments/{id}/holds ``` LIST the experiment’s holds, oldest first — one per paid call it reserved: the hold key, phase, state (reserved | settled | released | ambiguous), the reserved and settled amounts, the request ids, when it was reserved, claimed for dispatch and settled, when a claimed hold becomes a stale claim, whether it can be reconciled now, and the owner’s resolution + reason if it was. `?state=open` returns only the reserved and ambiguous holds — the ones that keep a screen waiting. Ids, money and timestamps only (never an arm, a terminal or a body). OWNER SESSION only (`flywheel.experiment`). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/holds" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Reconcile one open hold (settle, settle at reserve, or release) ```http POST /v1/flywheel/experiments/{id}/holds/reconcile ``` RECONCILE one OPEN hold — explicit and audited — so a screen held by money whose outcome is unknown can finish. Open means `ambiguous` (the call’s provider outcome was unknown: a dropped stream, a client cancel, an upstream 5xx, a 2xx without usage) or a STALE CLAIM (a hold claimed for dispatch that never settled — its request row never landed — past the hard request deadline plus a fixed margin; reason `stale_claim`). `resolution`: `settle` (the measured cost of the hold’s own request rows — refused `settle_cost_unknown` when a row is missing, unpriced or not a measurement, e.g. an interrupted call), `settle_at_reserve` (its full reserved amount — the conservative resolution: the caps never undercount money that may have been spent), or `release` ($0 — the owner asserts no provider charge). `reason` is a closed vocabulary (`stream_dropped`, `client_cancel`, `upstream_5xx`, `no_usage`, `stale_claim`, `operator`). Refusals by name: `hold_not_found`, `hold_not_open` (a settled / released hold, a hold claimed within its deadline, a hold never sent), `settle_cost_unknown`, `reason_invalid`. A repeat of the SAME resolution on a hold it already resolved converges and returns the stored result (`created: false`). Each resolution is audited (ids, amounts, resolution, reason, actor) and then re-evaluates the screen. OWNER SESSION only (`flywheel.experiment`), never an api key. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/holds/reconcile" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "hold_key": "string", "resolution": "settle", "reason": "string" }' ``` ## Record one preregistered blind judge’s screen verdict ```http POST /v1/flywheel/experiments/{id}/screen/judgements ``` RECORD one PREREGISTERED blind judge’s verdict on the screen’s final outputs (the judges run outside the platform): `verdict` (no_evident_degradation | evident_degradation) under the frozen `rubric_id`, the `reviewed_content_hashes` it judged and the final-content hashes it flags as a serious defect. Accepted only once every preregistered screen pair has its two complete executions (`judging_not_open` before that) and only when the reviewed set EQUALS the screen’s output manifest — every screen execution’s final content (`judgement_manifest_mismatch`); the manifest’s content address is stored with the judgement and enters the verdict’s inputs hash. One per judge — the same judgement converges, a different one is refused `judgement_conflict`. Other refusals by name: `judging_not_preregistered`, `judge_not_preregistered`, `rubric_mismatch`, `judgement_unknown_item` (a flagged hash is not a screen output). Recording a judgement re-evaluates the screen (the verdict needs both judges). OWNER SESSION only (`flywheel.experiment`); audited (ids / hashes only). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/screen/judgements" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "judge": "string", "rubric_id": "string", "verdict": "no_evident_degradation", "reviewed_content_hashes": [ "string" ] }' ``` ## Acknowledge a passed screen — the experiment then awaits test funding ```http POST /v1/flywheel/experiments/{id}/screen/acknowledge ``` ACKNOWLEDGE a passed screen: the owner has read it, and the experiment moves `screen_passed → awaiting_test_funding`. This opens nothing, funds nothing and adopts nothing — a screen pass proves nothing prospectively. Any other state is refused `illegal_transition` (409). OWNER SESSION only (`flywheel.experiment`); audited. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/screen/acknowledge" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Commit the content-addressed test manifest (before any test enrolment) ```http POST /v1/flywheel/experiments/{id}/test/manifest ``` COMMIT, in state `awaiting_test_funding` and BEFORE any test enrolment, EXACTLY N fresh pair identities (N from the preregistration) with per-pair body-free eligibility facts (the idea key, the quote ids it draws on, the ideation run id). Refusals by name: `manifest_size` (the wrong count), `pair_duplicate`, `pair_not_fresh` (an idea key in the fit or screen families), `quote_overlap` (a quote id a fit or screen receipt already used). Content-addressed + immutable: a different second manifest is refused `manifest_conflict`; the same one converges (`created: false`). OWNER SESSION only (`flywheel.experiment`); audited (the manifest hash + N only, never a body). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/test/manifest" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "pairs": [ { "idea_key": "string", "quote_ids": [ "string" ], "ideation_run_id": "string" } ] }' ``` ## Fund + open the prospective test phase ```http POST /v1/flywheel/experiments/{id}/test/fund ``` FUND the prospective `test` phase and OPEN it (`awaiting_test_funding → test_open`). Refuses unless the state is `awaiting_test_funding` and the test manifest is committed (`test_manifest_missing`); the cap must be positive and ≤ the preregistered parent cap’s REMAINING budget (`funding_exceeds_budget`); `approval` is 1–500 chars of the owner’s words + date, recorded in the audit event (never a body). It calls the ONE funding path and moves to `test_open` in the SAME transaction. A repeat with the same cap + approval converges (`created: false`); a different one is `already_funded`. OWNER SESSION only (`flywheel.experiment`); an api key is 403. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/test/fund" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "cap_micro_usd": 0, "approval": "string" }' ``` ## Record one preregistered blind judge’s per-item test verdict ```http POST /v1/flywheel/experiments/{id}/test/judgements ``` RECORD one PREREGISTERED blind judge’s per-item publishability verdict on the DELIVERED test outputs: `items: [{final_content_hash, publishable, serious_unsupported_claim}]` under the frozen `rubric_id`. Accepted only once every manifest pair has its two complete executions (`judging_not_open` before that) and only when the items EQUAL the test output manifest — every delivered final content (`judgement_manifest_mismatch`; `judgement_unknown_item` for an item outside it). One per judge — the same judgement converges, a different one is `judgement_conflict`; `judging_not_preregistered`, `judge_not_preregistered`, `rubric_mismatch` by name. Recording a judgement re-evaluates the test. OWNER SESSION only (`flywheel.experiment`); audited (ids / hashes only). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/test/judgements" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "judge": "string", "rubric_id": "string", "items": [ { "final_content_hash": "string", "publishable": true, "serious_unsupported_claim": true } ] }' ``` ## Evaluate the prospective test now (idempotent) and read it ```http POST /v1/flywheel/experiments/{id}/test/evaluate ``` EVALUATE the prospective test explicitly — the same exactly-once decision a recorded outcome, judgement or hold reconciliation runs. When every manifest pair is complete, no test hold is open and both judges have recorded, the ONE decision is computed and stored ONCE (concurrent calls make one verdict and one audit event; `computed` is true only for that call): the PRIMARY one-sided exact paired sign test, the ECONOMICS guard (ratio ≥ min AND a within-pair permutation p ≤ .05) and the weak QUALITY degradation screen. Returns the test read: counts only before the verdict, the decision + the Goodhart report after it. Idempotent; reads no body. Auth: an api key with `flywheel:experiment:run`, OR the owner session (`flywheel.experiment`). Requires API-key scope: flywheel:experiment:run. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/test/evaluate" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Abandon a live experiment ```http POST /v1/flywheel/experiments/{id}/abandon ``` ABANDON a live experiment — transition it to `abandoned` (terminal). A refused / closed / expired experiment cannot be abandoned (409). OWNER SESSION only (`flywheel.experiment`); audited. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/abandon" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## List the workspace confirmations — find what to adopt ```http GET /v1/flywheel/confirmations ``` LIST the workspace’s live-improvement confirmations, newest first. Each carries its candidate reference (the id the adopt/reject decision route takes), call type, lever family, the servable module id/version, its lifecycle status, the one-look live result (pending | confirmed | refuted | inconclusive) and whether it is adoptable now (open AND confirmed). This is the discovery surface a customer or SDK reads to find the CONFIRMED candidate to adopt via `POST /v1/flywheel/confirmations/{ref}/decision`. Read-only. Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC. NEVER a body. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/confirmations" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## List the inbox candidates — each with its offline deciding axis ```http GET /v1/flywheel/candidates ``` The Improvements-inbox candidate LIST: ONE list of every discrete candidate the flywheel produced (no lever filter), newest first. Each carries its screening run id, lever coordinates, a provenance chip DERIVED from the run’s own trigger evidence, its offline screening verdict and its live status. When a candidate has been screened it carries an `offline` DECIDING reading: the decision-contract intent it was decided under, the quality + cost intervals with their tri-states (labelled as the evidence tier they are — never a savings or quality claim), and the `deciding` axis (whether QUALITY or COST made the outcome) — so a customer can tell "did not help" from "helped but over your cost budget". Read-only. Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC. NEVER a body — ids/coordinates/interval numbers only. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/candidates" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Both goals for every call type — spend less AND get better ```http GET /v1/improvement-reads ``` MULTI-GOAL BY DEFAULT. For EVERY call type the workspace has, BOTH reads WITHOUT being asked: "a cheaper model is enough here" (the eval-backed model-migration recommendation evidence) AND "quality can improve here" (the Flywheel candidate + learning-plan evidence). Each half resolves to ONE closed state — `evidence` (a source row id + its evidence tier + an ESTIMATE label where it is one + the next action), `collecting` (n of a NAMED engine floor), `needs_confirmation` (a learning-plan proposal to confirm), `keep_incumbent` (the recommendation engine’s neutral result) or `unavailable` (a NAMED reason). A PURE composition over the existing engines — no new statistic, no ranking; an estimate is never shown as realized and a screened candidate is never shown as confirmed (fail-closed). The get-better default intent is `product_improvement`; a customer-stated intent is reported, never overridden. Read-only. Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC. NEVER a body — ids/tiers/counts/sentences only. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/improvement-reads" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Export the knowledge DB as a bundle — the customer’s to take ```http GET /v1/flywheel/knowledge/export ``` EXPORT the workspace’s knowledge DB as a portable, content-addressed bundle: every distilled fact, exemplar, prior and process pattern (each with its n / interval / window / lineage back to the customer’s OWN outcome events) plus the learned modules and their lift history. A DISTINCT object from the training-ready SFT export — the bundle is FOR the customer; any training runs only per-workspace under the `train_allowed` gate, never a shared or cross-workspace model (raw customer items never cross a workspace). Consent- + retention-bound: a revoked or suppressed item is dropped, items past retention are absent, and a workspace without export consent gets the NAMED refusal `EXPORT_CONSENT_REVOKED` (409). Deterministic: the same data reproduces a byte-identical bundle (generated_at excluded from the hash). Session + `flywheel.export` RBAC (owner|admin; a viewer is 403). NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/knowledge/export" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Live confirmation state for a candidate — status + one-look result + lift ```http GET /v1/flywheel/candidates/{ref}/confirmation ``` The LIVE confirmation state for a candidate reference: its lifecycle status, the ONE-LOOK result (pending | confirmed | refuted | inconclusive), whether a human may adopt it (adoptable ONLY when the confirmation is open AND its live result is confirmed — never a screening pass alone; the refusal is NAMED, e.g. `not_yet_confirmed_live`), and the recorded live lift (the treated − lever_holdout interval + n per arm). Read-only. Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC. NEVER a body. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/candidates/{ref}/confirmation" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## List the workspace proposals — find the plan ids to confirm ```http GET /v1/flywheel/plans ``` LIST the workspace’s learning-plan PROPOSALS, newest first. Each carries the plan id the confirm route (`POST /v1/flywheel/plan/{id}/confirm`) accepts, its workflow id, the lifecycle state, its provenance (`inferred` = derived from the inferred site brief / a zero-traffic seed; `declared` = derived from the workspace’s own traffic) and when it was created. This is the self-serve discovery surface a customer or SDK reads to find WHAT it may confirm — the dashboard derives the id client-side today. Read-only; a proposal never teaches. Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC. NEVER a body. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/plans" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Propose a named workflow’s learning plan (the history-first unblock) ```http POST /v1/flywheel/plans ``` A member proposes a NAMED call type’s workflow so a workspace with traffic but NO outcome events has a proposal to confirm — discovery only proposes the spend-ranked recommendation, so most workflows never get a proposal row. The server re-derives the SAME draft the confirm route reads and selects the candidate(s) carrying the call type. SESSION + `flywheel.write` RBAC (owner / admin / developer), NEVER an api key. No model call, no paid analysis is authorised. Each named refusal writes nothing and LEADS the error `message` with its stable refusal token (the error envelope carries no separate details field): `call_type_not_found` (404); `unknown_workflow` (409, no candidate carries it); `ambiguous_workflow` (409, > 1 candidate and `workflow_id` absent / not one of them — the candidate ids are in the message); `proposal_pending` (409, the workflow already has a pending proposal — its id is in the message); `workflow_confirmed` (409, a confirmed plan changes only through discovery’s material-change path). One pending proposal per workflow bounds it. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/plans" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "call_type_id": "string" }' ``` ## The learning plan for a workflow (the discovery projection) ```http GET /v1/flywheel/plan ``` The LEARNING-PLAN projection for one workflow: the derived workflow candidate, its call-type roles, the per-field provenance (structural / drafted / customer), the lifecycle state and the compiled TriggerSpec content address. A PROPOSED plan carries no `artifact_type_version_id` and no `spec_hash` — a proposal never teaches (the type + table boundary, re-checked at consumption). `plan` is null while the workflow is still `collecting` (an honest state, never a faked plan). Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC. NEVER a body. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/plan" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Record a learning-plan review verb ```http POST /v1/flywheel/plan/{id}/review ``` Record ONE review verb on a plan (confirm / edit / decline / defer / accept_diff / keep_mine / merge / split). The verb is PRODUCT TELEMETRY, never a correctness label; `decline` carries a coded reason from the closed menu. Session + `flywheel.write` RBAC (the session names its workspace via the `X-UR-Workspace` header); audited. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/plan/req_0000000000000000/review" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "action": "confirm" }' ``` ## Confirm a proposal (the human step that mints a teaching objective) ```http POST /v1/flywheel/plan/{id}/confirm ``` CONFIRM a proposed learning plan: mint the ConfirmedPlan objective version + compile its TriggerSpec + bump config_version, in one audited transaction. A PROPOSED objective never teaches, so this is a HUMAN step: SESSION + `flywheel.write` RBAC, NEVER an api key. The body carries only the customer's edits to the drafted bundle (value axis + decision policy + the optional `objective_metric` the customer CHOOSES when nothing has been observed yet). This is the SAME assembly the dashboard uses (one implementation). The response echoes `objective_source` (`declared` | `observed` | `inferred`), server-derived. Fail-closed: a missing plan is 404; a re-derivation miss / no-measurable-signal (pass `objective_metric` to declare one) is 409 with the coded reason. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/plan/req_0000000000000000/confirm" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "value_per_unit": "string", "decision_policy": "first" }' ``` ## The human accept/reject/revert press on a candidate confirmation ```http POST /v1/flywheel/confirmations/{ref}/decision ``` The HUMAN accept/reject/revert PRESS. An `accept` ADOPTS the candidate as an expiring lease ONLY when its live confirmation recorded a `confirmed` superior result (never on a screening pass alone); a `reject` closes the confirmation as refused; a `revert` is audit-only. Adoption is a HUMAN step: SESSION + `flywheel.write` RBAC, NEVER an api key. A MANDATORY coded reason. A human decision is HARD-WIRED to `learning_use:audit` (it can never train). Fail-closed: a missing/cross-workspace confirmation is 404; a not-yet-confirmed candidate is 409. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/confirmations/{ref}/decision" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "decision": "accept", "reason": "string" }' ``` ## Accept a lever the screen refused on cost — opens a controlled confirmation ```http POST /v1/flywheel/candidates/{runId}/accept ``` The EXPLICIT customer ACCEPT of a quality lever the offline screen refused ON COST ALONE (it helped, but it was over the stated cost budget). Such a lever stays a RECOMMENDATION, shown beside its ABSOLUTE economics: extra $ per 1,000 requests, errors avoided per 1,000 and $ per avoided error, each with its interval (an undefined ratio reads `unavailable`, never a number). A customer who reads those figures may accept the trade. The accept writes an audit event and OPENS A CONTROLLED CONFIRMATION — nothing more: the screen’s verdict is NOT rewritten (the response echoes the unchanged `offline_verdict`), nothing is adopted (`adopted` is always false) and no serving configuration changes; the candidate is served to nobody until the live comparison it opened produces a result and a human adopts THAT. SESSION + `flywheel.write` RBAC, NEVER an api key. A MANDATORY reason. Fail-closed: a missing run is 404; a run that was NOT cost-refused (a screened, parked or quality-refused one) or a full confirmation-slot pool is 409. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/candidates/{runId}/accept" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "reason": "string" }' ``` ## List customer-drafted hosted-prompt candidates — the inbox ```http GET /v1/flywheel/prompt-candidates ``` LIST the workspace’s customer-drafted hosted-prompt CANDIDATES, newest first: a non-active `source:human` hosted-prompt version that earned an offline screening on the SAME learned_context path. Each carries its screening run id (the id the approve/reject decision takes), the candidate version id + content sha (the diff-as-ids — never the template text), the derived run status (its screening verdict), the `human_draft` provenance chip, whether it is already the active version, and the NAMED one-lever-family approve gate (`one_family_open`) or null. This is the inbox discovery surface. Read-only. Auth: an api key with `config:read`, OR a Better Auth session + `flywheel.read` RBAC. NEVER a body. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/prompt-candidates" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Approve (activate) or reject a screened hosted-prompt candidate ```http POST /v1/flywheel/prompt-candidates/{runId}/decision ``` The HUMAN approve/reject PRESS on a screened customer-drafted hosted-prompt candidate. `approve` ACTIVATES that version through the hosted-prompt activation’s atomic path (a new immutable config version + a new learned_context epoch, gateway-applied) ONLY when the run parked at `screened`, the version is not already active, and NO other lever family holds an open confirmation for the call type (one lever family per call type in confirmation — else the NAMED `one_family_open`, 409); `reject` records the "did not help" archive reason. Adoption is a HUMAN step: SESSION + `flywheel.write` RBAC, NEVER an api key. A MANDATORY reason. A human decision is HARD-WIRED to `learning_use:audit`. Fail-closed: a missing/cross-workspace or non-hosted-prompt run is 404. NEVER a body. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/prompt-candidates/{runId}/decision" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "decision": "approve", "reason": "string" }' ``` ## Guided setup for the reviewer adapter (mapping preview + signature recipe) ```http GET /v1/flywheel/reviewer/setup ``` Everything needed to wire the ONE supported reviewer integration: the endpoint, the signed-webhook recipe (`HMAC-SHA256` over `"."`, keyed by your API key, in `X-UR-Reviewer-Signature` + `X-UR-Reviewer-Timestamp`), the required scope, the current `corrections_consent` state, and the MAPPING PREVIEW — what each of your reviewer verdicts becomes in the ledger, shown before a single event is recorded. NEVER echoes a secret. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/reviewer/setup" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Run one guided-setup reviewer scenario end to end ```http POST /v1/flywheel/reviewer/test ``` Sends a test artifact and an edited approved final through the REAL ingestion path and returns the JOINED pair. `scenario=replay` re-sends the identical event id (a `duplicate` — a visible no-op, never a second row); `scenario=version_mismatch` deliberately pins a version that does not exist, producing a dead letter for `/v1/flywheel/reviewer/repair` to fix. Test data NEVER enters learning (the reserved artifact carries no exposure arm, so the per-arm attribution rollup excludes it) or invoicing (the path is unmetered). Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/reviewer/test" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scenario": "happy" }' ``` ## Record signed reviewer decisions (the standard reviewer webhook) ```http POST /v1/flywheel/reviewer/events ``` The ONE supported reviewer-integration endpoint. Each event carries a stable `artifact_key` (or `artifact_id`), the EXACT `artifact_version` the human reviewed, the reviewer system’s own `event_id`, an `effective_at` and an OPTIONAL consented `final`. The request MUST be signed: `X-UR-Reviewer-Signature: sha256=` over `"."`, keyed by the API key you authenticate with — a missing, stale or invalid signature FAILS CLOSED (403) with a distinct named reason and nothing is written. Per event: a replay of an already-recorded `event_id` is `duplicate` (a visible no-op, never a second row and never an error); an artifact link that does not resolve in this workspace is `unmatched_artifact_key` / `wrong_tenant_artifact_id`; a stale pin is `version_mismatch` and is HELD for repair, never guessed; a final BODY without `corrections_consent` is refused and never stored. An `approved` decision with NO final records the decision and says `final_absent` — a missing final is NEVER auto-declared accepted. Provenance, `latency_class` and `learning_use` are SERVER-assigned; the event schema has no field for them, so an adapter cannot supply them and cannot impersonate another producer. The reviewer decides whether a final is approved — an edit is not an oracle of correctness. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/reviewer/events" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "events": [ { "event_id": "string", "artifact_version": 0, "decision": "approved", "effective_at": "string" } ] }' ``` ## Repair one dead-lettered reviewer event ```http POST /v1/flywheel/reviewer/repair ``` Re-drive ONE dead-lettered event with an explicit correction. We keep RECEIPTS (ids, hashes, named reasons), not payloads, so send the full original event your durable queue still holds. `repin_to_current_version` is OPT-IN — a stale pin is never silently corrected during normal ingestion, because “the human reviewed draft 2” and “draft 3 exists now” are different facts. A receipt that is already `accepted` is never rewritten (404 on an unknown event id). Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/reviewer/repair" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "event": { "event_id": "string", "artifact_version": 0, "decision": "approved", "effective_at": "string" } }' ``` ## Reviewer-integration health (the six named states + the next repair action) ```http GET /v1/flywheel/reviewer/health ``` Last successful event; unmatched / delayed / rejected events; duplicates absorbed; backfill progress per declared id; and the ONE next repair action with the event id it applies to. Every figure comes from the durable receipts, so health survives a restart and cannot disagree with what was ingested. Counts only — NEVER a body. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/reviewer/health" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Issue a planted-failure check to your verdict source ```http POST /v1/flywheel/source-checks ``` A claim-less task outcome counts toward the protected readiness floor ONLY when the source that reported it (the server-assigned producer of its credential) has shown it reports KNOWN failures as failures. Name a `call_type_id` — the known-good references are drawn from that call type’s GOLDEN SET (the references its compiled evaluator is qualified against; up to 50 distinct, seeded by the check id), never from items the source picks. The platform plants ONE failure (omission / wrong value / truncation / instruction loss) and ONE valid alternative per reference, renders them in one surface format, shuffles them and returns the probes ONCE. Answer EVERY probe with `POST /v1/flywheel/source-checks/{id}/verdicts` using the SAME credential. The probe bodies (golden-set inputs + probe outputs) are never stored (hashes + the hidden truth only) and never logged. Auth: an api key with `outcomes:write` AND `logs:read` (the probes carry the workspace’s golden-set content, which the default-granted `outcomes:write` alone never reads), OR a Better Auth session + `flywheel.write` RBAC. Refusals (409, named): `no_golden_set`, `insufficient_golden_items` (fewer than 10 distinct references), `insufficient_probes`, `source_revoked`. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/source-checks" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "call_type_id": "string" }' ``` ## Answer a planted-failure check — graded once into the qualification ledger ```http POST /v1/flywheel/source-checks/{id}/verdicts ``` The verdict source reports `resolved_true` / `resolved_false` for EVERY probe exactly once. PASS needs recall ≥ 0.8 on the planted failures AND specificity ≥ 0.9 on the valid alternatives (the evaluator-qualification bars) — an auto-close source that says success to everything FAILS. A pass qualifies the source (its task verdicts recorded FROM NOW ON count — never retro-qualified) or clears its review flag; a fail REVOKES it. Graded once into an append-only, versioned ledger. Only the SAME credential that issued the check may answer it (another source ⇒ 404); a missing / duplicate / unknown probe is a 400 and records nothing; a second grade is a 409. Auth: an api key with `outcomes:write`, OR a session + `flywheel.write`. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/source-checks/req_0000000000000000/verdicts" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "verdicts": [ { "probe_id": "string", "kind": "resolved_true" } ] }' ``` ## Verdict-source health — who may count toward readiness, and why not ```http GET /v1/flywheel/sources ``` Every verdict source of the workspace (the producers of artifact-level resolutions + every source with a check) with: `eligible` + the NAMED `reason` (`not_qualified` | `review_flag` | `eligible` | `revoked`), the qualified period and last clearance, the review flag over the outcomes since the last clearance (≥ 99 % success over ≥ 200 outcomes ⇒ held until a re-run check or a 40-task blind audit), `admitted_until` (history a passed ADMISSION audit vouches for — an import batch `import:` is its own source) and `revoked` (the credential was deleted — terminal), how many of its task verdicts count NOW (the SAME rule the readiness count applies), and its latest check / clearance audit / admission audit. Read-only. Auth: an api key with `config:read`, OR a session + `flywheel.read` RBAC. NEVER a body. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/sources" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Open a 40-task BLIND audit of a verdict source ```http POST /v1/flywheel/source-audits ``` Samples 40 INDEPENDENT tasks (distinct artifacts, deterministic) from ONE population of the source’s resolved task verdicts and returns their ids WITHOUT the source’s verdicts. `purpose: clearance` (default) — the human alternative to a re-run check for clearing a review flag: the post-clearance window of a QUALIFIED source. `purpose: admission` — history a check can never reach: an import batch (`import:`, one source per batch) or a live source’s verdicts recorded before its qualified period; a pass admits EXACTLY that population (never verdicts recorded later, never another batch). Label each task BLIND through the outcome API (`POST /v1/artifacts/{id}/outcomes`, a `resolution` event) — then grade. SESSION + `flywheel.write` RBAC only (an audit is human adjudication — never an api key). Refuses (named): the auditor is the source (400), a clearance on an unqualified source (409), a revoked source (409), fewer than 40 tasks (409). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/source-audits" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "verdict_source": "string" }' ``` ## Grade a blind audit from the labels recorded through the outcome API (never a one-click accept) ```http POST /v1/flywheel/source-audits/{id}/grade ``` Compares, per sampled task, the source’s verdict (up to the sampled population’s bound) with the auditor’s label recorded through the outcome API AFTER the audit opened (an earlier label is not blind and never grades it). ZERO mislabels over ≥ 40 tasks ⇒ `passed` (the Wilson 95 % upper bound on the source’s error rate is ≈ 8.76 %): a clearance clears the review flag; an admission admits the sampled population (`admitted_until`). One mislabel ⇒ `failed` (nothing changes). An unlabelled task ⇒ 400 `audit_incomplete`, nothing recorded — there is no accept button. Only the auditor who opened it may grade it; a second grade is a 409; a revoked source is a 409. The body is EMPTY and STRICT — an `accept` / override field is a 400. SESSION + `flywheel.write` RBAC only. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/source-audits/req_0000000000000000/grade" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{}' ``` ## Register a check (an oracle) — or append a new version of one ```http POST /v1/flywheel/oracles ``` A CHECK that can tell a right answer from a wrong one (a system that can PRODUCE the right answer sends a declared correction instead). Platform kinds (`label_set`, `schema_rules`) run in-process; a PUSH endpoint (`test_endpoint`, push `reconciliation`) is called by the platform through the SSRF egress guard (https, pinned IP, 64 KB, 10 s, JSON only, ≤ 2 re-validated redirects — validated here AND on every call); a PULL transport (`customer_ci`, pull `reconciliation`) is claimed by your worker / CI. The VERSION is the content hash of the canonical definition: the same spec is an idempotent no-op (200), a new spec appends a version (201) that supersedes the old one (its receipts stop admitting; a push / pull new version cancels the old version’s open jobs). A new push / pull oracle gets ONE HMAC signing secret, returned ONCE (`secret`; the audit keeps its last 4). The role (detector / certifier) is EARNED by qualification, never declared. Auth: platform kinds — an api key with `config:write`, OR a session + `config.write` RBAC; push / pull — a SESSION + the owner/admin `flywheel.oracle` RBAC ONLY (an egress target / a signing credential is a human decision, like `custom_provider.create`). Refusals: `invalid_spec` / `egress_blocked` (400), `oracle_not_found` (404), `oracle_revoked` / `kind_changed` (409). Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/oracles" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "label_set", "spec": {} }' ``` ## Read an oracle, the role its current version earned, and its transport state ```http GET /v1/flywheel/oracles/{id} ``` The current version + every version, the verdict source `oracle:@`, whether it was revoked, and the role reduced from the tenant qualification ledger (a canary false pass or any observed false pass means the version can never certify again — a fixed check is a new version). Push / pull checks add the transport state: the secret’s LAST 4 (never the secret), the canary pool, job counts by status, the latest qualification’s progress and the RESOLVED canary outcomes. Auth: an api key with `config:read`, OR a session + `flywheel.read` RBAC. Another workspace’s oracle is a 404. Requires API-key scope: config:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/flywheel/oracles/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Send test — prove a check end to end (nothing is graded) ```http POST /v1/flywheel/oracles/{id}/test ``` A platform check evaluates `probe` (or the synthetic probe) in-process. A PUSH check is called at once with the platform’s SYNTHETIC probe (never customer content) and the response returns the EXACT job payload sent + the verdict or a closed transport-error code (`egress_blocked`, `timeout`, `too_large`, `content_type`, `http_status`, `missing_signature`, `invalid_signature`, `version_mismatch`, …). A PULL check queues the synthetic job (`pending`) — claim + answer it like any job. Logged as a `test` run; never graded, never a canary. Auth: platform — an api key with `config:write` OR a session + `config.write`; push / pull — a session + `flywheel.oracle`. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/oracles/req_0000000000000000/test" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "probe": { "input": "string", "output": "string" } }' ``` ## Qualify the current version — the role is EARNED against planted failures ```http POST /v1/flywheel/oracles/{id}/qualify ``` From your ≥ 10 distinct known-good examples the platform plants TWO failures (omission / wrong value / truncation / instruction loss) + ONE valid rewrite per example, homogenized and shuffled, and your check judges every probe: DETECTOR = planted recall ≥ 0.8 AND control specificity ≥ 0.9; CERTIFIER = those bars + ZERO false passes over ≥ 73 planted probes. A platform check is graded in-process. A PUSH check receives every probe as an ORDINARY job (indistinguishable) and is graded when the last is answered (`incomplete` if any call errored — an error is never a pass). A PULL check’s probes wait for your worker (`pending`; graded when the last verdict arrives). Push / pull probes and the canary pool are stored as customer content: they need corrections consent AND full retention (409 `no_consent`). One graded event per qualification in the tenant ledger; a false pass is never erased. Auth: platform — an api key with `config:write` OR a session + `config.write`; push / pull — a session + `flywheel.oracle`. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/oracles/req_0000000000000000/qualify" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "examples": [ { "input": "string", "output": "string" } ] }' ``` ## Revoke an oracle (terminal) — every receipt of every version stops admitting ```http POST /v1/flywheel/oracles/{id}/revoke ``` Appends the terminal `source_revoked` for every version (idempotent), cancels every open push / pull job and drops the canary pool (the bodies are deleted). The oracle registers no new version and is never qualified again. The body is EMPTY and STRICT. Auth: platform — an api key with `config:write` OR a session + `config.write`; push / pull — a session + `flywheel.oracle`. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/oracles/req_0000000000000000/revoke" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` ## Rotate a push / pull oracle’s HMAC signing secret (returned once) ```http POST /v1/flywheel/oracles/{id}/rotate-secret ``` A fresh secret sealed by the workspace key (envelope encryption), returned exactly ONCE; the old secret stops verifying immediately (the workspace `config_version` is bumped). The audit keeps the last 4 of the new and the old secret only. A platform check has no secret (409 `transport_mismatch`). The body is EMPTY and STRICT. SESSION + `flywheel.oracle` RBAC only. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/oracles/req_0000000000000000/rotate-secret" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{}' ``` ## PULL transport — lease due jobs (each payload returned once) ```http POST /v1/flywheel/oracles/{id}/jobs/claim ``` Your worker / CI leases up to `max` due jobs for `lease_seconds`. Each payload (the task input + the candidate to judge) is returned in THIS response exactly once and then deleted; a qualification probe, a canary and an ordinary job look the same. The request is SIGNED with the oracle secret: `x-ur-oracle-signature: sha256=.">` + `x-ur-oracle-timestamp` (unix seconds, ±15 min) — a missing / stale / wrong signature is a 403 naming the reason. Auth: an api key with `outcomes:write` AND `logs:read` (the jobs carry stored content) + the signature. Refusals: `oracle_not_found` (404), `oracle_revoked` / `transport_mismatch` (409). Requires API-key scope: outcomes:write or logs:read. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/oracles/req_0000000000000000/jobs/claim" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "max": 0, "lease_seconds": 0 }' ``` ## PULL transport — post signed verdicts (idempotent per job) ```http POST /v1/flywheel/oracles/{id}/verdicts ``` One verdict per claimed job, bound to the SERVER-ISSUED job: it must echo the `job_id`, the `oracle_version` the job was issued under and the `candidate_sha256` it carried (a mismatch is refused by name, never recorded as an answer). SIGNED like the claim (a forged MAC is a 403). Per verdict: `accepted`; `duplicate` (the SAME verdict again — a visible no-op, forever); `conflict` (another answer for an answered job — never overwritten); `refused` with `job_not_found` | `job_not_claimed` | `job_closed` | `lease_expired` | `version_mismatch` | `candidate_mismatch` | `malformed_reply`. When nothing is accepted or duplicate the post is a 409 naming the first refusal. A canary answered `pass` is a FALSE PASS: the version gets `check_failed` and every receipt of it stops admitting. A qualification is graded when its last probe is answered. Auth: an api key with `outcomes:write` + the signature. No verdict ever writes a receipt. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/oracles/req_0000000000000000/verdicts" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "verdicts": [ { "job_id": "string", "oracle_version": "string", "candidate_sha256": "string", "verdict": "pass" } ] }' ``` ## Evaluate the controlled-rollout preconditions for a screened candidate (never presses) ```http POST /v1/flywheel/candidates/{ref}/rollout ``` The ONE server-side rollout-gate implementation. The CI run supplies the facts only IT observes (the current bound identity + the application CI gate’s headline); the SERVER decides the unlock from permissions, consent, budget, qualification and the frozen baseline, each REFUSING BY NAME. The response is ALWAYS `pressed: false` — pressing the button (live confirmation + adoption) is a human action in the dashboard, performed there, never by this endpoint. Auth: an api key with `outcomes:write`, OR a session + `flywheel.write`. NEVER a body. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/candidates/{ref}/rollout" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "schema": "ur.flywheel.rollout.request.v1", "candidate_ref": "string", "candidate_state": "string", "observed_identity": { "repository": "string", "baseSha": "string", "headSha": "string", "lockDigest": "string", "harnessHash": "string", "configHash": "string", "datasetSplit": "string", "workflowId": "string", "objective": "string", "evaluatorVersion": "string", "evidenceEpoch": "string" }, "application_headline": "string", "requires_repository_write": true, "change_shape": "string" }' ``` ## Record a human accept/reject/revert of a flywheel candidate (signed, replay-safe) ```http POST /v1/flywheel/candidates/{ref}/decisions ``` A human accept/reject/revert on a candidate, recorded as an AUDIT event + product feedback (the feedback is HARD-WIRED `learning_use:"audit"` — a human’s opinion of a candidate is never an independently-verified learning label). The candidate BODY never travels, only its reference. The request MUST be signed (`X-UR-AppCI-Signature: sha256=` over `"."`, keyed by the API key — the reviewer-webhook MAC recipe in a DISTINCT header namespace, so a captured reviewer delivery can never be replayed here) and is replay-safe on the deterministic `callback_id` (a replay is a visible `duplicate` no-op). A missing/stale/invalid signature FAILS CLOSED (403). Api-key only (the signed webhook needs a shared secret). Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/candidates/{ref}/decisions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "schema": "ur.appci.callback.v1", "kind": "decision", "callback_id": "string", "workspace_id": "string", "candidate_ref": "string", "evidence_hash": "string", "decision": "accept", "actor_id": "string", "reason": "string", "surface": "pull_request", "decided_at": "string" }' ``` ## List history imports (metadata only) ```http GET /v1/imports ``` Keyset-paginated import metadata + capability reports (never OFFSET). Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Import history (usage-only export or intact traces) ```http POST /v1/imports ``` Day-1 value without the proxy. Upload a USAGE-ONLY export (model/spend summaries — never a prompt, output, quality figure or replayable case) OR INTACT TRACES (request, response, occurred_at and an optional outcome). `mode=preview` returns the capability report with nothing stored; a commit ingests. Only intact, CONSENTED (corrections_consent), semantically eligible records become redacted capture-equivalent bodies + `exposure_arm=imported` artifacts (never a live-lift contrast). Per-record content-hash idempotency (resume never double-ingests). `call_type_id` names the call type the history belongs to — validated against the workspace’s call types and its CONFIRMED objective (named 404/409/400 refusals, nothing written), pinned on the batch (insert-only) and stamped on every accepted record’s artifact at assembly, so the batch’s task verdicts count toward that call type’s readiness once a blind ADMISSION audit of the batch passes; without it the batch is unbound and never counts. Owner/admin session + `flywheel.import`, OR an api key with `outcomes:write`. No paid analysis is authorized by an upload. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "format": "string" }' ``` ## Get a history import (metadata + capability report) ```http GET /v1/imports/{id} ``` One import + its capability report (accepted/duplicate/rejected/unsupported). Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/imports/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Cancel a non-terminal history import ```http POST /v1/imports/{id}/cancel ``` Cancel a `preview`/`running` import (→ `cancelled`). A terminal import is a 409. Cancellation releases nothing to spend (no budgets) and preserves per-record idempotency. Requires API-key scope: outcomes:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/imports/req_0000000000000000/cancel" \ -H "Authorization: Bearer $UR_API_KEY" ``` --- # Generation source: https://docs.ultrarouter.io/docs/api/generation (https://docs.ultrarouter.io/docs/api/generation.md) Post-hoc per-request stats (OpenRouter parity). This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Get post-hoc stats for one request ```http GET /v1/generation ``` OpenRouter `/generation` parity — cost/tokens/timing/routing for one prior request. Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/generation" \ -H "Authorization: Bearer $UR_API_KEY" ``` --- # Invitations source: https://docs.ultrarouter.io/docs/api/invitations (https://docs.ultrarouter.io/docs/api/invitations.md) Workspace invitations. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## List invitations ```http GET /v1/workspaces/{id}/invitations ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/invitations" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Invite a member ```http POST /v1/workspaces/{id}/invitations ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/invitations" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "email": "string", "role": "string" }' ``` ## Accept an invitation ```http POST /v1/invitations/accept ``` Accepts the raw token from the JSON body or the `?token=` query (the mailed link). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/invitations/accept" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "token": "string" }' ``` --- # Keys source: https://docs.ultrarouter.io/docs/api/keys (https://docs.ultrarouter.io/docs/api/keys.md) API-key introspection. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Introspect the presented API key ```http GET /v1/key ``` The presented key describes itself: scopes, caps, and usage this period. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/key" \ -H "Authorization: Bearer $UR_API_KEY" ``` --- # Knowledge source: https://docs.ultrarouter.io/docs/api/knowledge (https://docs.ultrarouter.io/docs/api/knowledge.md) Knowledge endpoints. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Search company knowledge ```http GET /v1/knowledge ``` Workspace members or config:read/config:write keys. Knowledge and corrections consent required; unavailable content is omitted. Lessons include stored text and atomic metadata; citations expose metadata only. Facts expose allowlisted derived statistics and structured scope, never arbitrary bodies. Current candidates are not active or proven. No training or provider calls. Existing consent and erasure rules apply. Evidence availability refers to retained metadata, not raw source bodies; correction-body TTL does not expire derived lessons. snapshot_at is an observation time, not a durable database snapshot. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/knowledge" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Export retained company knowledge content ```http GET /v1/knowledge/export ``` Session-only owner/admin with 2FA and flywheel.export permission. Requires knowledge, corrections and training_export consent. Audited explicit content export ur.company_knowledge/v1; preserved /v1/flywheel/knowledge/export remains the metadata bundle. Normalized selection filters are included in the hash and manifest; complete is true only when this page contains every match of that selection, truncated includes omitted preceding pages. Resume with next_cursor; clocks and cursor excluded from content_hash. Exclusions explicitly identify unsupported/raw content. No import, activation or owned model weights. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/knowledge/export" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Read one company knowledge item ```http GET /v1/knowledge/{id} ``` Workspace members or config:read/config:write keys. Knowledge and corrections consent required; unavailable content is omitted. Lessons include stored text and atomic metadata; citations expose metadata only. Facts expose allowlisted derived statistics and structured scope, never arbitrary bodies. Current candidates are not active or proven. No training or provider calls. Existing consent and erasure rules apply. Evidence availability refers to retained metadata, not raw source bodies; correction-body TTL does not expire derived lessons. snapshot_at is an observation time, not a durable database snapshot. IDs: `fact:` or `lesson::`. Unknown, foreign or erased items return 404. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/knowledge/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" ``` --- # Learning source: https://docs.ultrarouter.io/docs/api/learning (https://docs.ultrarouter.io/docs/api/learning.md) Per-call-type learning controls — status, self-serve enable, pause / resume / disable, versions, evaluations, rollback and manual promote / reject. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Get the learning status of a call type ```http GET /v1/call-types/{id}/learning ``` The learner’s state (`off`, `collecting`, `learning`, `paused`, `disabled`, `unavailable`, `quota_exhausted`, `budget_exhausted`) and its reason; the reflection model and whether it resolves to a workspace-owned provider; the promotion policy; the ACTIVE version (the incumbent) and the latest candidate; the running evaluation; the evidence arrived vs verified vs incorporated vs backlog; whether the next reflection will run and why; the budget / quota state; and token usage separate from cash. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Enable learning for a call type (self-serve) ```http POST /v1/call-types/{id}/learning/enable ``` Turns learning on with a reflection model, a promotion policy and a recipe. Requires the workspace’s corrections and knowledge consent (409 `conflict`, message `corrections_consent_required` if either is missing). The owner grants both through the dashboard Allow learning opt-in or the session-authenticated `PUT /v1/workspaces/{id}/self-learning` with `{"enabled":true}`; a bearer key cannot grant workspace consent. Also requires a reflection model that resolves to a WORKSPACE-OWNED offer — a custom provider or a provider the workspace stored its own key for (400 `invalid_request`, message `reflection_model_not_workspace_owned`). No staff grant is needed. A published version is a candidate. In evaluate mode it receives a randomized share of eligible episodes during comparison; only a win makes it the active version. Re-enabling updates the policy; a running evaluation keeps its frozen rule. Owner/admin session or a `config:write` key. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/enable" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reflection_model": "string" }' ``` ## Pause learning ```http POST /v1/call-types/{id}/learning/pause ``` Stops reflecting and stops evaluation decisions; the ACTIVE version keeps serving and no evaluation arm is assigned while paused. Idempotent. 409 `conflict` (`learning_not_enabled`) for a call type that was never enabled through this API. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/pause" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Resume learning ```http POST /v1/call-types/{id}/learning/resume ``` Resumes reflection and evaluation decisions and resets the bounded-retry counters (a learner that stopped after repeated failures or quota refusals tries again). Idempotent. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/resume" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Disable learning (kill switch) ```http POST /v1/call-types/{id}/learning/disable ``` Stops serving lessons and stops reflecting within one 5 s config window (the episode-memory kill switch); a running evaluation stops (`stopped_manual`). Versions and history are kept; enabling again resumes from the active version. Idempotent. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/disable" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## List learned versions with their states ```http GET /v1/call-types/{id}/learning/versions ``` The immutable versions, newest first, each with its state (`candidate`, `evaluating`, `active`, `retired`, `rejected`, `superseded`), why and since when. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/versions" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Get one learned version ```http GET /v1/call-types/{id}/learning/versions/{version_id} ``` The version’s active lessons, how many retired, the evidence it was learned from, the body-free reflection receipt (hashes and token counts) and every recorded state transition. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/versions/{version_id}" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Roll back to an earlier version ```http POST /v1/call-types/{id}/learning/rollback ``` Points the active version at an EARLIER immutable version (`version_id`, or the version active immediately before the current one) so it serves exactly that version’s lessons again. A running evaluation stops. 409 `conflict` (`no_rollback_target` / `rollback_target_not_earlier`) when there is nothing to roll back to. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/rollback" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "version_id": "string" }' ``` ## Promote a version to active (manual decision) ```http POST /v1/call-types/{id}/learning/versions/{version_id}/promote ``` An owner/admin (or `config:write` key) decision: the version becomes the active version now; a running evaluation stops. 409 `conflict` (`version_already_active`) when it already is. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/versions/{version_id}/promote" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Reject a candidate (manual decision) ```http POST /v1/call-types/{id}/learning/versions/{version_id}/reject ``` An owner/admin (or `config:write` key) decision: the candidate is never activated by the system; its running evaluation stops. 409 `conflict` (`version_active`) for the active version — roll back instead. Idempotent. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/versions/{version_id}/reject" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## List evaluations ```http GET /v1/call-types/{id}/learning/evaluations ``` Every evaluation, newest first: the frozen rule and its hash, each arm’s verified / pass / unknown episodes, the one-sided interval on the success difference, and the decision with its reason. Under lpr2, enrollment closes before outcomes are inspected; the frozen episodes remain selected while their outcomes are awaited. Missing outcomes cannot produce a promotion. Historical lpr1 results retain their original rule. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/evaluations" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Get the tool documentation the learner reads ```http GET /v1/call-types/{id}/learning/tool-documentation ``` The call type’s customer-supplied tool documentation as stored: per tool its name, schema fingerprint, parameter names and documented constants (never description prose), plus the set’s `etd1_` digest. Empty (`tool_count` 0, `digest` null) when none is stored. Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:read or config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/tool-documentation" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Replace the tool documentation the learner reads ```http PUT /v1/call-types/{id}/learning/tool-documentation ``` For agents whose tools are not sent as native `tools[]` (a code-style `api.(…)` protocol): up to 256 OpenAI-style function specs (unique names; body at most 1 MiB). Each is stored as a bounded summary — name, schema fingerprint, parameter names and the constants it documents (enum / const / default / a number or quoted token its description states); description prose is never stored. When learning admits a lesson (`elv2`), such a constant is admissible only in a lesson that names the documenting tool. Replaces any stored set; an empty `tools` list clears it; the same set again is a no-op (`changed: false`). Erased with the learning data when corrections consent is revoked. 400 `invalid_request` (`tool_documentation_too_large`, `tool_documentation_invalid ()`). Dual auth: an api key (`config:read` to read, `config:write` to change) or an owner/admin session with the `X-UR-Workspace` header; a viewer may read only. A call type of another workspace is a 404. Requires API-key scope: config:write. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X PUT "$UR_BASE_URL/v1/call-types/req_0000000000000000/learning/tool-documentation" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tools": [ { "type": "function", "function": { "name": "string" } } ] }' ``` --- # Members source: https://docs.ultrarouter.io/docs/api/members (https://docs.ultrarouter.io/docs/api/members.md) Workspace membership management. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## List workspace members ```http GET /v1/workspaces/{id}/members ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/members" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Change a member role ```http PATCH /v1/workspaces/{id}/members/{userId} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/req_0000000000000000/members/user_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "role": "string" }' ``` ## Remove a member ```http DELETE /v1/workspaces/{id}/members/{userId} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X DELETE "$UR_BASE_URL/v1/workspaces/req_0000000000000000/members/user_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` --- # Models source: https://docs.ultrarouter.io/docs/api/models (https://docs.ultrarouter.io/docs/api/models.md) The model catalog and per-provider offers. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## List the model catalog ```http GET /v1/models ``` The active model catalog with pricing + capabilities, filterable by provider/capability. Each item separates PROVENANCE from HOSTING: `origin_jurisdiction` + `weights` describe the LOGICAL model (the developing lab — an offer inherits them from the model it serves), while `hosting_jurisdiction` + `hosting_region` describe where THIS offer RUNS. A CN-origin open-weight model hosted in the US shows `origin_jurisdiction: CN`, `weights: open`, `hosting_jurisdiction: US`. Any unestablished fact is `unknown` (fail-closed). Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/models" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Get a model with its per-provider offers ```http GET /v1/models/{id} ``` Logical-model detail incl. every active provider offer. The `{id}` carries the provider-prefixed slash id (e.g. `openai/gpt-5.2`). Provenance vs hosting: top-level `origin_jurisdiction` + `weights` are the LOGICAL model's provenance; each offer carries its OWN `hosting_jurisdiction` + `hosting_region` (where that offer runs). Unestablished facts are `unknown` (fail-closed). Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/models/req_0000000000000000" \ -H "Authorization: Bearer $UR_API_KEY" ``` --- # Ops source: https://docs.ultrarouter.io/docs/api/ops (https://docs.ultrarouter.io/docs/api/ops.md) Operational probes and smoke endpoints. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Key-auth echo ```http GET /v1/echo ``` Proves API-key auth + the versioned config cache end-to-end. Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X GET "$UR_BASE_URL/v1/echo" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Liveness probe ```http GET /healthz ``` The process is up and serving; never touches dependencies. Auth: Public (no auth). ```bash curl -sS -X GET "$UR_BASE_URL/healthz" ``` ## Readiness probe ```http GET /readyz ``` Dependencies are usable (DB reachable + migrations current). 503 when not ready. Auth: Public (no auth). ```bash curl -sS -X GET "$UR_BASE_URL/readyz" ``` --- # Passthrough source: https://docs.ultrarouter.io/docs/api/passthrough (https://docs.ultrarouter.io/docs/api/passthrough.md) Passthrough endpoints. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Create a response (OpenAI Responses API passthrough) ```http POST /v1/responses ``` Native passthrough for the OpenAI Responses API: point the official `openai` SDK at ultrarouter by changing only the base URL and the key. The JSON body is FORWARDED VERBATIM to OpenAI (no routing, no compression, no model remapping, no field injection) and the response is returned UNTOUCHED — the gateway only meters, caps and captures. `stream:true` forwards the upstream SSE bytes verbatim. The ur extensions ride request headers (`X-UR-Call-Type`, `X-UR-Timeout-Ms` clamped 1 s–600 s (default 300 s), `X-UR-Trace`); the response carries `X-UR-Request-Id` and (non-streaming) `X-UR-Cost-Micro-USD`. An allowlist of request headers (`openai-beta`, `openai-organization`, `openai-project`, `idempotency-key`) is forwarded upstream; the caller's own `Authorization` is never forwarded. Upstream errors pass through with their status and body; gateway failures (auth, caps, timeout) use the normalized error envelope. EXPERIMENT-BOUND calls: a call carrying `X-UR-Experiment-Execution` (an execution enrolled on `POST /v1/flywheel/experiments/{id}/executions`; the key also needs `flywheel:experiment:run`) is authorized against its execution, reserved against the experiment budget before dispatch and settled from the measured cost; it must send `max_output_tokens`, a call type and an `attempt` in `X-UR-Trace`. The learned pack is never placed into a Responses body (a treated lever-stage call here is refused by name). A call without the header is unaffected. Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/responses" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "string" }' ``` ## Create a message (Anthropic Messages API passthrough) ```http POST /v1/messages ``` Native passthrough for the Anthropic Messages API: point the official `@anthropic-ai/sdk` at ultrarouter by changing only the base URL and the key. The ur key is accepted via `Authorization: Bearer` OR `x-api-key` (the Anthropic SDK style); the body is FORWARDED VERBATIM to Anthropic and the response is returned UNTOUCHED — the gateway only meters, caps and captures. `stream:true` forwards the upstream SSE bytes verbatim. The ur extensions ride request headers (`X-UR-Call-Type`, `X-UR-Timeout-Ms` clamped 1 s–600 s (default 300 s), `X-UR-Trace`); the response carries `X-UR-Request-Id` and (non-streaming) `X-UR-Cost-Micro-USD`. An allowlist of request headers (`anthropic-version`, `anthropic-beta`, `idempotency-key`) is forwarded upstream; the caller's own key headers are never forwarded. Upstream errors pass through with their status and body; gateway failures (auth, caps, timeout) use the normalized error envelope. EXPERIMENT-BOUND calls: a call carrying `X-UR-Experiment-Execution` (an execution enrolled on `POST /v1/flywheel/experiments/{id}/executions`; the key also needs `flywheel:experiment:run`) is authorized against its execution (workspace, live experiment, open phase, bound candidate) BEFORE dispatch, reserved against the experiment budget from `max_tokens` and the dispatched bytes, claimed once (a retry of the same `attempt` is refused) and settled from the measured cost. For the TREATED arm’s lever call type only, the frozen learned pack is placed as a NEW FIRST TEXT BLOCK of the first user message — `system` and every `cache_control` marker are untouched; other stages and the control arm are forwarded unchanged. Refusals are named in the error message. A call without the header is unaffected. Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/messages" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "string" }' ``` --- # Preflight source: https://docs.ultrarouter.io/docs/api/preflight (https://docs.ultrarouter.io/docs/api/preflight.md) Ask what the gateway would decide before a non-proxied call — model, fallbacks, learned knowledge, placement. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Ask what the gateway would decide — model, fallbacks, learned knowledge, placement, grant ```http POST /v1/preflight ``` For a customer who does NOT proxy through the gateway: which model + fallbacks would route (the SAME routing engine the proxy runs), which learned module would inject (through the ONE shared resolve→rebuild→admit path the inject seam runs) and WHERE (`after_stable_prefix`), PLUS the exposure `grant` so ONE call gives both what to apply AND the arm it is in. The grant is minted by the SAME sanctioned owner the `POST /v1/flywheel/grants` route uses — supply an `artifact_key` or `trace_id` to receive one (else `grant` is null with `grant_reason`). The standing holdout is honoured: a holdout-arm grant withholds the learned module in the same response (`knowledge_reason: "holdout"`). Profile level only — there is no request-to-situation ranker. Two cache lifetimes: the module/pin/candidates part is config-versioned (`Cache-Control: private, max-age=5` + a strong `ETag`; `If-None-Match` ⇒ 304); the health-ordered fallbacks are the 30 s live part (`routing.max_age_s`). The grant is NEVER cached — it is excluded from the ETag and served on the uncached `X-UR-Grant` header (a 304 revalidation still carries a fresh one). Requires the `gateway:invoke` scope (a READ of what an invoke would do). NEVER a body/PII in logs. Requires API-key scope: gateway:invoke. Auth: API key (`Authorization: Bearer ur_sk_…`). ```bash curl -sS -X POST "$UR_BASE_URL/v1/preflight" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "call_type": "string" }' ``` --- # Provider keys source: https://docs.ultrarouter.io/docs/api/provider-keys (https://docs.ultrarouter.io/docs/api/provider-keys.md) BYOK provider keys (write-only secrets). This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## List provider keys ```http GET /v1/workspaces/{id}/provider-keys ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/provider-keys" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Register a BYOK provider key ```http POST /v1/workspaces/{id}/provider-keys ``` The secret is envelope-encrypted at rest; responses carry last-4 + health only. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/provider-keys" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "provider_id": "string", "secret": "string" }' ``` ## Update a provider key ```http PATCH /v1/workspaces/{id}/provider-keys/{keyId} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/req_0000000000000000/provider-keys/key_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "priority": 0 }' ``` ## Delete a provider key ```http DELETE /v1/workspaces/{id}/provider-keys/{keyId} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X DELETE "$UR_BASE_URL/v1/workspaces/req_0000000000000000/provider-keys/key_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Health-check a provider key ```http POST /v1/workspaces/{id}/provider-keys/{keyId}/health-check ``` Runs one cheap probe and returns the refreshed health status. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/provider-keys/key_0000000000000000/health-check" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` --- # Workspaces source: https://docs.ultrarouter.io/docs/api/workspaces (https://docs.ultrarouter.io/docs/api/workspaces.md) Workspace tenancy management. This page is GENERATED from the OpenAPI spec (`openapi.yaml`) — it can never drift from the served API. Set `UR_BASE_URL` (required: your gateway’s URL) and `UR_API_KEY` (an `ur_sk_*` key); secrets are always the `$UR_API_KEY` placeholder. ## Read the workspace model policy ```http GET /v1/workspaces/{id}/model-policy ``` The workspace MODEL POLICY (which models this workspace may run): a preset (`us_only` | `global` | `custom`) plus per-model/per-provider/per-lab allow-deny rules and the US-hosted open-weight exception. Default `global` when unset. RBAC `model_policy.read`. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/model-policy" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Replace the workspace model policy ```http PUT /v1/workspaces/{id}/model-policy ``` REPLACE the workspace MODEL POLICY. Enforced everywhere a model is picked (routing/fallbacks, eval/bench candidates, recommendations, flywheel/eval replays) through the ONE versioned-config snapshot; a denied model is a pre-dispatch `model_denied_by_policy` refusal. owner|admin, audited, bumps `config_version`. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PUT "$UR_BASE_URL/v1/workspaces/req_0000000000000000/model-policy" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "preset": "us_only" }' ``` ## Read the workspace native-passthrough routing policy ```http GET /v1/workspaces/{id}/passthrough-policy ``` The per-workspace NATIVE-PASSTHROUGH routing policy: stage-keyed rules that let the gateway swap a `POST /v1/responses` / `POST /v1/messages` request’s model from its call tags (`X-UR-Call-Type` / `X-UR-Trace.stage` / body model), optionally injecting Anthropic prefix caching — the product form of moving a cheap stage off the frontier model with no code change. Default disabled when unset. RBAC `passthrough_policy.read`. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/passthrough-policy" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Replace the workspace native-passthrough routing policy ```http PUT /v1/workspaces/{id}/passthrough-policy ``` REPLACE the per-workspace native-passthrough routing policy. Applied on `POST /v1/responses` and `POST /v1/messages`: the FIRST matching rule rewrites the body’s model to its served model (SAME provider family only — a cross-family rule is refused here by name), which must be priced and active in the catalog; everything else in the body is left verbatim. owner|admin, audited, bumps `config_version` (visible within one 5 s cache window). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PUT "$UR_BASE_URL/v1/workspaces/req_0000000000000000/passthrough-policy" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "enabled": true, "rules": [ { "id": "string" } ] }' ``` ## List the workspace live-injection experiment packs ```http GET /v1/workspaces/{id}/experiment-packs ``` LIST the workspace experiment packs, newest first — ids / `module_hash` / token count / source / created + revoked timestamps only (never the module text). owner|admin session (`flywheel.write`). Auth: Session cookie + workspace RBAC. ```bash curl -sS -X GET "$UR_BASE_URL/v1/workspaces/req_0000000000000000/experiment-packs" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Load a live-injection experiment pack ```http POST /v1/workspaces/{id}/experiment-packs ``` Load a small, experiment-labeled learned PLAYBOOK (a `ur:learned` reference block ≤ 800 tokens) that a passthrough rule’s `inject` can bind to a (call type × api key). A matching `POST /v1/messages` / `POST /v1/responses` request gets the pack inserted into the learned reference slot, stamped, bounded and fail-safe. The module TEXT is stored in encrypted blob storage under `corrections_consent` — a create is REFUSED when consent is off, when the text exceeds 800 tokens by the core estimator, or when it is not a `ur:learned` shape. NOT a product lever adoption, NOT a holdout arm. owner|admin session (`flywheel.write`), audited (ids / hashes only); never returns the text. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/experiment-packs" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "call_type_id": "string", "module_text": "string" }' ``` ## Revoke a live-injection experiment pack ```http DELETE /v1/workspaces/{id}/experiment-packs/{packId} ``` REVOKE an experiment pack (set `revoked_at`). A revoked pack is no longer injected on any matching request — the gateway serves verbatim with a recorded skip — within one 5 s config window (a revoke bumps `config_version`). The module body rides the corrections-body retention sweep. owner|admin session (`flywheel.write`), audited. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X DELETE "$UR_BASE_URL/v1/workspaces/req_0000000000000000/experiment-packs/{packId}" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` ## Preview a proposed model policy against the live catalog ```http POST /v1/workspaces/{id}/model-policy/preview ``` Evaluate a PROPOSED policy against the LIVE catalog WITHOUT mutating anything: allow/deny counts by lab and by provider, the denied logical models, and the input-price range lost. Exactly what the dashboard onboarding wizard shows. RBAC `model_policy.read`. Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/req_0000000000000000/model-policy/preview" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "preset": "us_only" }' ``` ## Create a workspace ```http POST /v1/workspaces ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "string" }' ``` ## Rename a workspace ```http PATCH /v1/workspaces/{id} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/req_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "string" }' ``` ## Soft-delete a workspace ```http DELETE /v1/workspaces/{id} ``` Auth: Session cookie + workspace RBAC. ```bash curl -sS -X DELETE "$UR_BASE_URL/v1/workspaces/req_0000000000000000" \ -H "Cookie: better-auth.session_token=$UR_SESSION" ``` --- # ultrarouter Bench source: https://docs.ultrarouter.io/docs/bench (https://docs.ultrarouter.io/docs/bench.md) **ultrarouter Bench** turns the dev/test traffic you already send through the gateway into a paired benchmark you can trust — no eval team, no uploaded dataset, no leaderboard contamination. The dataset is _your own captured traffic_. Bench is an **optional** front door to the same evidence engine that powers passive [audit mode](/docs/concepts/audit). You never need it to get value: a base-URL/key swap already gives you passive readiness. Bench is the accelerator you reach for when you want a real, reproducible answer to "is this candidate model / prompt / config at least as good — and cheaper per task — than what I run today?", and a **CI gate** that fails a PR when it is not. ## What it does, end to end 1. **Capture** — tag ordinary calls with a suite (`X-UR-Bench-Suite`, the SDK `bench` option, or the `ur.bench` body field). The gateway persists the full, encrypted, replayable execution envelope. Test-key traffic captures; a live key is refused (a consent boundary, not a failure). 2. **Freeze** — snapshot the confirmed captures into an immutable, content-addressed, per-call-type **weighted dataset** with a **dataset card** (unique/cluster counts, strata, exclusions, a pre-registered **power plan**). 3. **Run** — a paired run re-invokes your current incumbent _and_ each challenger over the frozen dataset, contemporaneously, through the same execution profile. 4. **Report** — an immutable, content-addressed evidence report: quality and all-in cost intervals per call type × arm, cost views labeled by basis, a **trust class** (A/B/C), a dual-interval **keep / migrate / inconclusive** verdict, and downloadable, offline-verifiable evidence bundles. 5. **Gate** — pin a run as the suite baseline and gate CI on the interval-based compare verdict with `ur bench run --gate`. ## Read next - [**Bench quickstart**](/docs/bench/quickstart) — zero to a captured suite, a frozen dataset, a paired run, a report, and a CI gate, using only shipped surfaces. - [**Bench evidence & honesty**](/docs/bench/concepts) — dataset cards, power plans, paired-vs-retained compare, dual-interval verdicts, trust classes, evidence bundles, and the estimate/provider-reported labeling rules. - [**Bench CI gate**](/docs/bench/ci-gating) — the `ur bench run --gate` exit-code contract and the GitHub Action inputs. - [**Bench API reference**](/docs/api/bench) — the generated `/v1/bench/*` routes. ## What Bench is not Bench never invents a second eval engine, a second cost basis, or a second savings number. Every figure it shows references the same immutable run/cost lineage as the rest of ultrarouter, and it never converts an `inconclusive` result into a pass. A Bench report is proof of a **paired comparison**, not a claim of realized cash — the [savings ledger](/docs/concepts/savings) is where realized cash lives. --- # Bench CI gate source: https://docs.ultrarouter.io/docs/bench/ci-gating (https://docs.ultrarouter.io/docs/bench/ci-gating.md) The `ur` CLI (shipped in the `@ultrarouter/sdk` package) turns a Bench suite into a CI gate: it captures bench-tagged traffic, freezes the suite, re-runs your **current** prompt/pin/config as the candidate, compares it against a pinned baseline under the [dual-interval verdict](/docs/bench/concepts#dual-interval-verdicts) contract, and exits non-zero when the change is not safe. The `ur` CLI and the GitHub Action live in the public TypeScript SDK repository, [lucastonon/ultrarouter-sdk-typescript](https://github.com/lucastonon/ultrarouter-sdk-typescript), and drive the shipped `/v1/bench/*` API. They target whatever gateway `UR_BASE_URL` names: the invite-only hosted gateway (`https://api.ultrarouter.io`), a local stack or your own deployment. Nothing on this page depends on the hosted service. ## The command ```bash ur bench run \ --capture "" \ # a command that drives your app/tests through the gateway --budget 3 \ # per-run USD cap (bounds the atomic allowance reservation) --gate \ # exit non-zero on a fail/inconclusive verdict --baseline bench:main \ # a br_* run id, or a symbolic ref → the suite's pinned baseline --json # versioned machine output (ur.bench.gate.v1) ``` `--capture ` spawns your command with `UR_BENCH=` and `UR_BENCH_RUN=` exported (the run tag defaults to `GITHUB_SHA`, else the short git SHA), so any traffic it sends through the gateway is captured into a fresh run. `UR_API_KEY` (a `ur_sk_test_…` key) and `UR_BASE_URL` come from the environment, or pass `--api-key` / `--base-url`. Two companion subcommands manage the baseline: ```bash ur bench baseline # pin a completed run as the suite baseline (e.g. after merge to main) ur bench gate # read a compare run's gate and exit per the verdict ``` ## Exit-code contract The gate maps the tri-state verdict to a CI-friendly exit code, and **fails closed** — a broken gate is never silently green. | Exit | Meaning | | --- | --- | | `0` | `pass` — both the quality non-inferiority and cost-savings intervals cleared their bars, no ceiling breach. Also an `inconclusive` verdict when `--allow-inconclusive` is set. | | `1` | A usage/argument error (bad flags). Distinct from a gate failure. | | `2` | `fail` (a proven regression, a hard-scorer break, or a per-unit cost-ceiling breach), an `inconclusive` verdict (unless `--allow-inconclusive`), **or any API/pipeline error** (fail closed). | Key flags that shape the verdict: - `--allow-inconclusive` — treat an `inconclusive` gate as a pass (exit `0`). Off by default: unproven is not green. - `--cost-ceiling ` — an absolute per-unit cost bound that fails the gate **independently** of quality. - `--mode request|task` — the paired-runner mode. `task` (whole-trace, outputs fed forward) is the only mode that yields `$/task` evidence; `request` (the default) yields `$/request`. - `--pin-baseline` — on a `pass`, advance the suite baseline to this run. - `--run-tag ` — override the default commit-SHA run tag. A `deduped` result (an identical run within 14 days) resolves server-side to the linked prior run, so idempotent CI re-runs are free and still gate correctly. ## Machine output (`--json`) `--json` emits a versioned object tagged `ur.bench.gate.v1` (snapshot-tested, so the schema is stable across releases): the suite, run tag, resolved baseline, the tri-state `verdict`, the `exit_code`, and the full server gate object. Parse `verdict` / `exit_code` in your pipeline rather than scraping the summary table. ## GitHub Action A published composite Action wraps the CLI. It installs the `ur` bin, runs the gate, and fails the job on a non-zero exit. ```yaml - uses: lucastonon/ultrarouter-sdk-typescript@v0.0.1 with: suite: checkout-agent api-key: ${{ secrets.UR_BENCH_KEY }} # a ur_sk_test_… key, stored as a repo secret base-url: https://your-gateway.example.com baseline: bench:main budget: "3" capture: pnpm test:agent ``` The Action's Bench inputs map one-to-one to the CLI flags: | Input | Default | Purpose | | --- | --- | --- | | `mode` | `bench` | Which gate the Action runs; this page covers `bench`. | | `suite` | _(required)_ | The Bench suite slug to run + gate. | | `api-key` | _(required)_ | A test-mode Bench key (`ur_sk_test_…`), from a repo secret. | | `base-url` | _(required)_ | The gateway base URL. | | `baseline` | `bench:main` | A `br_*` run id or a symbolic ref → the suite's pinned baseline. | | `budget` | `3` | Per-run USD budget cap (bounds the atomic reservation). | | `capture` | `""` | A shell command that drives traffic through the gateway (bench env exported). | | `cost-ceiling` | `""` | An absolute per-unit provider-reported cost ceiling (USD); a breach fails the gate. | | `paired-mode` | `request` | `request` or `task` (the only `$/task` evidence). | | `allow-inconclusive` | `false` | When true, an inconclusive gate exits `0`. | | `pin-baseline` | `false` | On a pass, advance the suite baseline to this run. | | `run-tag` | `""` | A fresh run tag (default the commit SHA). | | `sdk-version` | the Action's release | The `@ultrarouter/sdk` version to install for the `ur` CLI (a pinned Action runs a pinned CLI). | ## What a gate proves (and does not) A green gate proves that, on the pinned baseline dataset and under the registered margins, your current config is non-inferior in quality and clears its cost-savings bar — a **paired comparison**, not a claim of realized cash. Pair it with the [savings ledger](/docs/concepts/savings) for what a change actually saves in production, and download an [evidence bundle](/docs/bench/concepts#evidence-bundles) for an offline-verifiable record. --- # Bench evidence & honesty source: https://docs.ultrarouter.io/docs/bench/concepts (https://docs.ultrarouter.io/docs/bench/concepts.md) Bench is designed so a report can be handed to a skeptical reviewer — an engineer, a finance partner, an auditor — and survive scrutiny. This page defines the vocabulary a report uses and the honesty rules behind it. Read it before you quote a Bench number. ## Dataset cards Freezing a suite produces one immutable, content-addressed **weighted dataset** per call type, described by a **dataset card**. The card is the provenance of everything downstream: - **Unique and cluster counts.** Exact duplicates are collapsed but retain their frequency weight; near-duplicates form visible clusters rather than disappearing. The decision unit is the independent cluster, not the raw call count. - **Strata and weights.** The same frozen population weights are shared by the quality and cost views, so they can never disagree about what the population is. - **Exclusions.** PII redaction always runs; a low-confidence item is excluded with a visible per-item reason (unless the suite carries an audited attestation admitting it post-redaction). Test and Bench traffic never leak into production golden sets. - **A pre-registered power plan** (below). Identical captures always produce an identical `dataset_hash` — the card is content-addressed and independent of envelope ids, so a re-freeze is a no-op. ## Power plans The dataset card carries a **power plan**: a planning statement of the form "this dataset can detect about an _X_ percentage-point quality change at 80% power", derived from a conservative-prior / A-A variance estimate. It also states the **minimum detectable effect (MDE)**. The MDE is a **power diagnostic only — it is never the verdict.** A small dataset with a wide MDE does not fail; it produces an `inconclusive` verdict with the MDE stated so you know why. Raw item count never establishes decision power on its own. ## Paired vs. retained compare Two things Bench deliberately does _not_ do: - **It never trusts a historical output as a control.** Your current incumbent is **re-invoked and charged** in the same run as each challenger, contemporaneously, from the same pinned item envelope through the same execution profile. A captured historical output is provenance, not a perfect counterfactual. - **It never re-invokes an output for scoring without charging it.** Under explicit evaluation-retention consent, the exact billed output is retained (encrypted, purpose-limited, TTL-bounded) so cost and quality are proven from the **same** retained invocation. Without that artifact the observation is ineligible — never silently re-invoked and mislabeled as paired. **Compare mode** runs your _current_ effective config as the single arm and pairs it, by content hash, against a pinned **baseline** run's retained arm artifacts. A missing or expired baseline artifact makes that pair ineligible and degrades the verdict toward `inconclusive` — it is never silently reported as green. ## Dual-interval verdicts Every migration decision is an interval-based tri-state on the frozen population. Two paired cluster-bootstrap intervals are computed on the same items: 1. a **quality non-inferiority interval**, and 2. a **cost-savings interval**. The verdict is: - **`pass`** — only when **both** registered bars clear (the quality interval's lower bound stays above the non-inferiority margin **and** the cost-savings interval's lower bound clears the registered minimum-savings bar). - **`fail`** — when a regression is established, a hard-scorer or established outcome gate breaks, or an absolute per-unit cost ceiling is breached (the ceiling fails **independently** of quality). - **`inconclusive`** — otherwise. A quality pass with an uncertain cost interval is `inconclusive`; a cost pass with a hard-outcome regression is `fail`. A `keep` / `inconclusive` outcome is first-class and always carries a **named remediation** (e.g. capture more traffic, register an outcome, resolve an evaluator question) — a report never pretends an unproven result is a win. The pre-registered margins state their snapshot provenance (the pinned run spec, never live config), so a mid-flight config edit can never move an in-flight verdict. Multiplicity across finalists and repeated/sequential re-runs are controlled, so a repeated-winner loop cannot inflate the false-positive rate. ## Cost views and the provider-reported vs. estimate rule A cost figure is only ever labeled by its **basis**, and the unit is explicit: - **`$/task`** is used **only** when a true task runner fed outputs forward and closed the outcome (or a proven deterministic one-request oracle applies). Otherwise a figure is **`$/request`**. - Token components are shown as **billed units**, never as a universal efficiency unit (different tokenizers are not fungible). The word **`measured`** labels a cost figure only when its usage was provider-reported and replayable (or invoice-reconciled). It is **never** applied to an `estimated` (chars÷4) usage basis — that is labeled a discovery estimate. Per-token list or blended price is a discovery input only: it can shortlist a candidate but it can never rank, emit, or auto-apply a migration. Any savings percentage a surface shows is labeled as an estimate or a projection, never a bare headline number. ## Trust classes Every run and every metric carries an evidence-class tag and a composite **trust class**, so a reader always knows how the quality signal was produced: - **Class A** — at least ~70% of the composite weight is programmatic scorers plus first-party outcomes (your CI assertions, `ur.bench.expect`/`ur.bench.record`). The strongest, judge-light evidence. - **Class B** — a mix, still weighting programmatic and outcome evidence above the judge. - **Class C** — judge-dominant. Legitimate, but disclosed as such and reported with the judge's calibration (agreement kappa). Programmatic assertions and first-party outcomes always outweigh the LLM judge; when the deterministic tiers cover every applicable risk, the judge residue is retired entirely (a zero-judge run). A same-provider-family judge is rejected per pair. This Bench trust class (A/B/C) is distinct from the evidence-interoperability trust class used for imported external scores. ## Evidence bundles Any completed run exports an **evidence bundle** (`?format=bundle` on the report, or the SDK/CLI). A bundle is a self-contained, content-addressed record of the frozen dataset card, the pinned run spec, the immutable per-arm cost evidence, the run's pinned stats, and first-party outcomes — with every decision-gated figure linked to its lineage ids. A bundle **verifies offline**: `pnpm evidence:verify ` re-checks it with no network and no ultrarouter account, so the proof survives even after a customer leaves. The report reads no realized-ledger table — source separation is enforced so a realized saving can never be inferred from a Bench report, and the incumbent renders its actual re-invoked cost evidence, never a fictitious $0 placeholder. ## Where this leaves you A Bench report proves a **paired comparison** under stated evidence and limitations. It is not a claim of realized cash — realized cash lives on the invoice-reconciled [savings ledger](/docs/concepts/savings) after you actually migrate. Bench tells you, honestly and reproducibly, whether a change is worth making; the ledger tells you what it saved once you made it. --- # Bench quickstart source: https://docs.ultrarouter.io/docs/bench/quickstart (https://docs.ultrarouter.io/docs/bench/quickstart.md) This walks an agent (or a human) from nothing to a paired Bench report and a CI gate using only shipped surfaces: the raw REST API, the `@ultrarouter/sdk` `bench` option, the `ur` CLI, and the GitHub Action. Every stateful step is a real `curl` you can run; the reachability checks at the end are executed against the live gateway in CI (DOC-31), so a broken example fails the build. Set your gateway and key first — the invite-only hosted gateway is `https://api.ultrarouter.io`; running the stack locally (`pnpm dev:e2e`) the base URL is `http://localhost:8080`; or point at your own deployment. ```bash export UR_BASE_URL="http://localhost:8080" export UR_API_KEY="ur_sk_test_…" # a TEST-mode key (ur_sk_test_*), not a live key ``` Bench capture requires a **test-mode key** (`ur_sk_test_…`). Tagging a **live** key with a bench signal is refused with `400 invalid_request` (a consent boundary — the message names both fixes: use a test key, or have a workspace owner enable `bench_allow_live_capture`). The stateful steps below also need a key with the bench scopes: `config:read` (status), `config:write` (freeze), and `bench:run` (runs, baseline, gate, report). Mint one in the dashboard or via the API. ## 1. Capture — tag your traffic Add one header to the calls you already make. Nothing about how the request is served changes; the gateway durably captures the execution envelope off the hot path and returns an honest `ur.bench` echo (`capture_status: "queued"` — never `captured: true`; only a persisted, replayable envelope is `confirmed`). ```bash curl -sS "$UR_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -H "X-UR-Bench-Suite: checkout-agent" \ -d '{ "model": "openai/gpt-5-mini", "messages": [{ "role": "user", "content": "Draft a refund reply." }], "call_type": "refund-reply" }' # → 200; the response carries "ur": { "bench": { "suite": "checkout-agent", # "capture_status": "queued" } } ``` The suite auto-creates on first use. Run whatever produces representative traffic — your integration tests, a staging click-through, or an agent harness. Every distinct `call_type` becomes its own benchmark. With the SDK, set it once at construction: ```ts import UltraRouter from "@ultrarouter/sdk"; const ur = new UltraRouter({ apiKey: process.env.UR_API_KEY, bench: "checkout-agent" }); // every request — including streams and retries — now carries the bench headers. ``` ## 2. Check readiness The readiness meter reports confirmed capture counts per call type and, once frozen, the qualified dataset card. It speaks in qualified-evidence terms — there is no raw-count "n ≥ 30 so you're ready" verdict (see [power plans](/docs/bench/concepts#power-plans)). ```bash curl -sS "$UR_BASE_URL/v1/bench/suites/checkout-agent/status" \ -H "Authorization: Bearer $UR_API_KEY" # scope: config:read ``` ## 3. Freeze into an immutable dataset Freezing snapshots the confirmed captures into an immutable, content-addressed, per-call-type weighted dataset (exact dedupe keeps frequency; near-duplicates cluster; PII is redacted and low-confidence items are excluded with a visible reason). It returns each **dataset card**. Re-freezing identical captures is idempotent — the same `dataset_hash`. The optional `max_items` caps the per-call-type frozen-item count (default 500; hard ceiling 5000). Over-cap uniques drop keeping the highest frequency and are recorded as `over_cap` exclusions, and the dataset card's item count reflects what actually runs. ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/checkout-agent/freeze" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "max_items": 500 }' # scope: config:write; omit the body for the default cap ``` ## 4. Plan (or run) a paired evaluation The planner estimates one paired eval per otherwise-runnable (frozen, ≥ 10 unique) call type. A mandatory `max_cost_usd` is one global umbrella ceiling. Start with `dry_run: true`: it returns the estimate without any mutation, reservation, or provider call. Add `require_power: true` to turn the pre-spend power advisory into a gate: an `underpowered` call type (its `power.mde_pp` exceeds the registered `margin_pp` at its `cluster_n`) becomes `not_runnable(underpowered)` and is dropped from the estimate before any budget is reserved — so `n=9` never spends against a margin it can't detect. Each disposition also carries a `power` block (`cluster_n`, `required_clusters`, `mde_pp`, `margin_pp`, `underpowered`) and the response surfaces `judge_worst_case_usd` additively (never summed into `estimate_usd`); an unaffordable judge phase is refused at the trigger with `judge_phase_unaffordable` before any arm spend. ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/checkout-agent/runs" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "max_cost_usd": 3, "dry_run": true, "require_power": true }' # scope: bench:run # → estimate + judge_worst_case_usd + remaining allowance + per-call-type dispositions # (each with its power block); require_power parks underpowered call types not_runnable. ``` Live paid dispatch is controlled by the `bench.paid_dispatch` kill switch (an operator flag, default **ON**). On any seeded stack (dev/e2e, or a self-hosted deployment after `db:seed`) the switch is ON, so **omitting `dry_run` DISPATCHES a real paired run** — it consumes a monthly run slot, reserves against your prepaid allowance under the umbrella `max_cost_usd` ceiling, and makes paid provider calls. Every child settles its allowance hold truthfully (the full actual spend is debited once, unspent reservation refunded). When an operator flips the switch **OFF** (or on an un-seeded database, where an unknown flag fails closed), live dispatch is refused with HTTP 503 `atomic_spend_guard_unavailable` before any reservation, row creation, queueing, or provider call — and every `dry_run` disposition that would otherwise be `runnable` is instead labeled `not_runnable` with `atomic_spend_guard_unavailable`, so a blocked shape is never mislabeled runnable. ## 5. Read the report The report is immutable and content-addressed — a re-read is byte-stable. It renders, per call type × arm, the cost views labeled by basis (`$/request` vs `$/task`, token components as billed units), the pinned [trust class](/docs/bench/concepts#trust-classes), and the [dual-interval verdict](/docs/bench/concepts#dual-interval-verdicts) with a first-class keep/inconclusive outcome and a named remediation. ```bash curl -sS "$UR_BASE_URL/v1/bench/runs/{run_id}/report" \ -H "Authorization: Bearer $UR_API_KEY" # JSON (default) curl -sS "$UR_BASE_URL/v1/bench/runs/{run_id}/report?format=bundle" \ -H "Authorization: Bearer $UR_API_KEY" > bundle.json # an offline-verifiable evidence bundle ``` Verify a downloaded bundle offline — no network, no ultrarouter account — with `pnpm evidence:verify bundle.json`. ## 6. Gate CI Pin a completed run as the suite baseline, then gate every PR on the compare verdict. See the [CI gate page](/docs/bench/ci-gating) for the full exit-code contract and the GitHub Action. ```bash ur bench baseline {run_id} # pin the baseline once ur bench run checkout-agent --capture "pnpm test:agent" \ --budget 3 --gate --baseline bench:main --json # exit 0 = pass, 2 = fail/inconclusive ``` ## Verify the Bench surface is live These checks run against your gateway with your key and prove the Bench API is served and auth-gated end to end. They are executed in CI (DOC-31). The stateful steps above need a captured suite and a bench-scoped test key; these reachability checks need neither, so they stay hermetic. ```bash exec curl -sS --fail "$UR_BASE_URL/openapi.json" \ | grep -q '/v1/bench/suites/{slug}/freeze' \ && echo "ok: the Bench API surface is published" ``` ```ts exec const res = await fetch(`${process.env.UR_BASE_URL}/openapi.json`); if (!res.ok) { console.error("HTTP", res.status); process.exit(1); } const spec = await res.json(); const benchPaths = Object.keys(spec.paths).filter((p) => p.startsWith("/v1/bench/")); if (benchPaths.length === 0) { console.error("no bench routes in the spec"); process.exit(1); } console.log(`ok: ${benchPaths.length} Bench routes are published`); ``` The readiness meter is a real, scope-enforced route. This proves it is wired against your gateway (`200` with a `config:read` key on a known suite, `403` when the key lacks the scope, `404` for an unknown suite — all "wired" outcomes): ```bash exec code=$(curl -sS -o /dev/null -w '%{http_code}' \ "$UR_BASE_URL/v1/bench/suites/quickstart-demo/status" \ -H "Authorization: Bearer $UR_API_KEY") case "$code" in 200|403|404) echo "ok: the Bench readiness meter is wired (HTTP $code)" ;; *) echo "unexpected HTTP $code"; exit 1 ;; esac ``` ## Next steps - Understand the evidence: [dataset cards, power plans, verdicts, trust classes](/docs/bench/concepts). - Wire the [CI gate](/docs/bench/ci-gating) into your pipeline. - The full [Bench API reference](/docs/api/bench) is generated from the spec. --- # Changelog source: https://docs.ultrarouter.io/docs/changelog (https://docs.ultrarouter.io/docs/changelog.md) The authoritative, detailed changelog lives in the monorepo `CHANGELOG.md`. This page summarizes what is shipped and documented here, by phase. ## Plug-and-play learning (preview) - **2026-10-29 — choosing BYOK funding is part of setup.** A new workspace starts on prepaid UR credits, which never pay your own provider keys or a provider you run. Choose BYOK funding once with `POST /v1/workspaces/{id}/billing/funding` ([Providers & BYOK](/docs/guides/providers-byok#funding-choose-byok-to-use-your-own-keys)). Until then such a request is refused with HTTP 403 `permission`, which names that route. It used to be a retryable 502 `provider_unavailable`. - **2026-10-29 — [Get started](/docs/get-started) and copy-to-agent setup.** Connect your agent, wire a trusted outcome signal, turn on learning, then watch, pause or roll back, or copy one prompt into Claude Code or Codex. The prompts come from versioned setup recipes, served at [`/setup-recipes.json`](/setup-recipes.json). The differences between the two coding agents are on [Set up with Claude Code or Codex](/docs/guides/agent-integrations); the MCP servers and plugins, built from source, on [MCP servers for Claude Code and Codex](/docs/guides/mcp-servers). The [evidence status](/docs/evidence-status) page states what is and is not demonstrated: learning is a preview, and improvement for a given agent is not yet demonstrated. ## Phase 10 — the Flywheel (documentation) - **2026-09-20 — the no-training promise was reversed.** Earlier docs carried a blanket no-training promise. That promise has been reversed: ultrarouter may now train and evaluate models **exclusively for a workspace**, behind a six-conjunct `train_allowed` gate (never a shared or cross-workspace model; raw items never cross a workspace). The switches and the `train_allowed` enforcement **are built** (row TRN0b); what is not built yet is a governed trainer, so no customer-data training run happens until it ships. See [How training is governed](/docs/guides/training-export#how-training-is-governed). - The broad Flywheel documentation set, agent-first: the five-product overview ([Products](/docs/concepts/products)), the outcome-loop concept ([The Flywheel](/docs/concepts/flywheel)), and the evidence-integrity concept ([Holdout and lift](/docs/concepts/holdout-and-lift)) — the standing holdout, the exposure arms, superiority, and why a merged PR is never a lift. - The [Record outcomes](/docs/guides/outcomes) guide: the thesis walkthrough in TypeScript, Python and curl across all five recording modes — instant, delayed, batch, warehouse backfill (the `ur outcomes` CLI) and the durable-queue flush — with the raw-REST path executed against the live stack by the snippet gate. - The [Self-learning agents](/docs/guides/self-learning-agents) guide (FLW-71): point an unmodified OpenAI-compatible agent at the gateway with a key that carries a default call type, keep the agent unchanged, and report one outcome per finished episode — the gateway serves the agent its own verified past episodes (most similar first, with the actions that worked) on each step. TypeScript and Python examples over the stock OpenAI SDK read `x-ur-request-id` from the raw response and post the outcome. It states what is learned and what never is (only reported passes; the holdout untouched; consent-gated; identifiers redacted; per-workspace, never shared — retrieval, not training) and makes no performance claim while the held-out confirmation is still running. The call type's **Agent memory** panel in the dashboard shows the status, the verified/unverified counts and a small learning trend. - The [Import history](/docs/guides/import-history) guide: usage-only vs intact-trace classes, the capability report, per-record idempotency, and repairable per-row receipts. - The [training-ready export](/docs/guides/training-export) guide: what the export contains, what `not_trainable` means (a first-class label, never a silent drop), the owner-only consents that gate it, and how any training is governed (per-workspace, behind `train_allowed`). - Two laws documented as they are enforced in code: **a proposed objective never teaches** (a compiler guarantee) and **the holdout is sacred** (no lever ever reads it). Flywheel learning surfaces remain honestly `unavailable` / `collecting` until a live confirmation has run. - The [context runtime](/docs/guides/context-runtime) guide (DOC-51 / CTX-10..60): the pure lift → compile → render → exit path with real SDK code (TypeScript and Python where parity exists), compatibility profiles, memory classes, the lazy SOP catalog and phase gate, native-delivery adapters and the ONE inline fallback, adopt-in-one-file / leave-in-one-file, and the Flywheel learned slot. The gateway is a witness of `ur.manifest`, never its author. - Product-select blocks for agents (DOC-51): five ≤ 300-token "choose when / not when" blocks on [For agents](/docs/for-agents) — Router, Evals, Scoring, Flywheel and Context — each verifiable through the docs MCP. The [AGENTS.md snippet](/docs/agents-snippet) gains the one-line outcome instruction (POST what happened next after an answer is accepted or corrected) and a product-select pointer. ## Phase 6 — SDKs & agent-first docs - TypeScript SDK (`@ultrarouter/sdk`) and Python SDK (`ultrarouter`), OpenAI-shaped with the `ur.*` extensions, contract-tested against the live stack. - The OpenAPI spec is generated from the gateway's zod route schemas and drift-gated in CI; served at `/openapi.{json,yaml}`. - This documentation site (Fumadocs): agent surfaces (`llms.txt`, `llms-full.txt`, `.md`, the docs MCP server), the human IA, and the docs-drift + snippet-exec CI gates. - A [Bench section](/docs/bench): the [quickstart](/docs/bench/quickstart) (capture → freeze → paired run → report → CI gate), the [evidence & honesty](/docs/bench/concepts) vocabulary (dataset cards, power plans, dual-interval verdicts, trust classes, evidence bundles), and the [CI gate](/docs/bench/ci-gating) exit-code contract + GitHub Action. ## Phase 5 — routing & commitments - Continuous evals feed model-migration recommendations. - Canary rollouts with automatic rollback-on-dip, promotion, and a kill switch. - Commit-aware marginal routing and renewal packs. ## Phase 3 — compression - Deterministic transform tier (whitespace + exact dedupe), property-tested. **Superseded by the [CMP-60 safety taxonomy](/docs/concepts/compression): reclassified as `semantic_safe`, off by default, evidence-qualified — not a semantic-identity guarantee.** - Opt-in `adaptive` tier with never-touch rules, a verbatim-intent guard, verify-and-retry, a 40 ms budget, and a per-call-type circuit breaker. - Shadow testing + per-call-type compression reporting. ## Phase 2 — dashboard - The customer control plane: overview, call types, logs explorer with export, providers/BYOK, savings statements, and settings (retention, webhooks, members). ## Phase 1 — gateway - The OpenAI-compatible proxy: chat completions (+ streaming), embeddings, the model catalog, effective-config precedence, the routing engine, hot-path protection (idempotency, spend caps, per-key RPS), BYOK + custom providers, and the remaining `/v1` surface. ## Phase 0 — foundations - Tenancy + RLS, auth + RBAC + 2FA, API keys + the versioned config cache, the operational spine (audit, flags, telemetry, rate limits, jobs), and the real-HTTP e2e layer. --- # Concepts source: https://docs.ultrarouter.io/docs/concepts (https://docs.ultrarouter.io/docs/concepts.md) ultrarouter sits on the request hot path and applies five layers of value, each tunable and observable. Before any of them, [**audit mode**](/docs/concepts/audit) passively learns your traffic the moment you change the base URL and key — zero config, zero changes to how requests are served. 1. [**Call types**](/docs/concepts/call-types) — a label per workload that carries its own routing + compression config, resolved through a documented precedence chain. 2. [**Routing**](/docs/concepts/routing) — pinned, multi-offer fallback, and auto-routing across providers, with deadlines and provider preferences. 3. [**Compression**](/docs/concepts/compression) — a prompt-compression safety taxonomy: off by default, an opt-in deterministic tier, and a guarded adaptive tier. 4. [**Evals**](/docs/concepts/evals) — continuous, judge-backed evaluation that turns into model-migration recommendations and canary rollouts. 5. [**Savings**](/docs/concepts/savings) — an append-only ledger that computes honest, procurement-defensible savings. Every request records which layer decided each field, so "why did this call use model X, this provider, this compression?" is always answerable. --- # Audit mode source: https://docs.ultrarouter.io/docs/concepts/audit (https://docs.ultrarouter.io/docs/concepts/audit.md) **Audit mode** is what ultrarouter does the moment you point an OpenAI-compatible client at it with only the base URL and API key changed. It is **passive**: no model switch, no provider change, no config edit, no retention upgrade. It quietly learns what your calls are trying to do and produces a **readiness report** — and nothing about how your requests are served changes. This is the passive subset of the zero-configuration Audit Autopilot. The full lifecycle (evaluator building, benchmarking, recommendations, canary, verified) is covered under [evals](/docs/concepts/evals) and rolls out on top of what audit observes. ## What you get with zero configuration Send ordinary traffic — no `call_type`, no `ur.*` fields, no Bench headers — and the audit builds: - **Inferred call types** with a confidence level and evidence coverage. Stable request shapes are fingerprinted; once enough similar requests accumulate, a call type surfaces on its own. Below that threshold they appear as **emerging shapes**. - **An inventory** of the models, providers and key modes it has seen. - **A benchmark-readiness map** that says plainly *what evidence is unavailable and why* under your current privacy settings — rather than retaining request bodies secretly. - **A discovery savings queue** — cheaper candidate models shortlisted by list price, ranked by spend. These are **discovery estimates only**. They are never a savings claim and are never applied automatically. Read it at [`GET /v1/audit/readiness`](/docs/api) or on the **Audit** screen in the dashboard. ## The `observing` state Each call type carries a lifecycle state. In audit mode every call type is born `observing` — the entry into that state is recorded with an evidence reason (it either emerged from explicit use or crossed the auto-inference threshold). No state in audit mode influences routing or config: **audit has zero authority.** ## Honest evidence, honest money Audit never labels an estimate as measured. Cost evidence is only **replayable** (provider-reported) for a call type when a durable, consented capture exists for it; otherwise it is **estimate-only**, and the report says so. Every savings figure in audit mode is a **discovery estimate**, kept strictly separate from the realized [savings ledger](/docs/concepts/savings). ## Privacy: nothing is retained without consent Audit honors your existing payload-retention setting. Under the default `metadata` retention, request and response bodies are **never** stored, so replay-grade benchmarking is unavailable — the report names that gap instead of quietly keeping bodies. To bound volume, no-consent shape metadata is deterministically **sampled** (a stable hash of the request id — never random), and the sampling is shown in the report's coverage, not hidden. To unlock replay-grade evidence for a workload, enable full payload retention or a Bench suite. Neither is required to start: the base-URL/key swap is enough. ## Failure behavior Analysis runs off the request path. If it fails, your request is unaffected and the failure becomes an actionable diagnostic — audit **fails open for serving and closed for optimization**. It can surface an exploration hint; it can never change how a request is served. --- # Call types source: https://docs.ultrarouter.io/docs/concepts/call-types (https://docs.ultrarouter.io/docs/concepts/call-types.md) A **call type** is a label for a workload (e.g. `support-reply-draft`, `code-review`). Each call type carries its own configuration — model pin, router mode, compression mode, fallbacks, provider preferences — so you tune behavior per workload instead of per request. Attach one with the `call_type` field (or the `X-UR-Call-Type` header; the body wins): ```json { "model": "openai/gpt-5-mini", "messages": [{ "role": "user", "content": "…" }], "call_type": "support-reply-draft" } ``` The first time a call type is used it is **auto-created** — call types emerge from usage. You can also manage them programmatically via [`/v1/call-types`](/docs/api/call-types) or in the dashboard. ## Effective-config precedence Every tunable resolves through one documented five-level chain. The first layer that defines a field wins: 1. **Request** — fields on the request body (or `X-UR-*` headers). 2. **Per-key defaults** — an API key's `config_defaults`. 3. **Call-type config** — the call type's active config version. 4. **Workspace defaults** — the workspace's default config version. 5. **System defaults** — the built-in defaults (compression `off`, router `off`, provider sort `price`, …). `model` has no system default — a request with no model and no configured pin is a `400 invalid_request`. The winning layer per field is recorded on the request, so the dashboard can show the effective value, its source, and how often requests override it. ## Config as versions Every config change is an **immutable new version** plus a repoint plus a config-version bump, committed in one transaction. Rollback is re-pointing to a prior version — nothing is edited in place. The gateway reads config through a 5-second versioned cache, so any change (a new pin, a revoke) is visible within one cache window with no distributed invalidation. --- # Compression tiers source: https://docs.ultrarouter.io/docs/concepts/compression (https://docs.ultrarouter.io/docs/concepts/compression.md) ultrarouter can compress prompts on the hot path to cut token cost. Any change to model-visible tokens can change an LLM result, so ultrarouter never calls prompt compression "lossless" — the word **lossless** is reserved for byte-preserving transport/storage. Compression is classified by a safety **taxonomy**, and it is **fail-safe**: any error dispatches the original, uncompressed payload. Set `compression` on the request or per call type: `"off"` (default), `"semantic_safe"`, or `"adaptive"`. ## The safety taxonomy | Tier | What it does | When it runs | | --------------- | --------------------------------------------------------------------------- | ------------ | | `off` | No model-visible rewrite — byte-identical dispatch. **The default.** | Always available; the initial mode for an unseen call type. | | `semantic_safe` | Deterministic, bounded transforms: whitespace normalization + exact-context dedupe. Code and JSON are preserved byte-for-byte. | Opt-in, and (going forward) earned per call type by shadow evidence. | | `adaptive` | History windowing, boilerplate removal, and extractive pruning under a strict safety net. Deletes model-visible information. | Opt-in, always guarded, canaried and rollback-protected. | A new workspace performs **no model-visible rewrite** until a transform has earned activation for its call type. History deletion and substring boilerplate removal are never in a "safe without evidence" tier — they are always `adaptive`. The `ur.compression` block on each response reports the applied tier and its provenance: ```json { "applied": "off", "source": "off" } ``` ```json { "applied": "semantic_safe", "source": "unqualified-legacy", "deprecated_alias": "lossless", "tokens_saved": 84, "est_saved_usd": 0.0002, "savings_basis": "provider_reported" } ``` `source` is `unqualified-legacy` when a tier ran without shadow qualification (the qualification engine is being built); `deprecated_alias` is set when the request used a legacy value. ## Legacy values The legacy `compression: "lossless"` and `compression: "auto"` values remain parseable during migration, but they are **not** semantic-identity guarantees: - `"lossless"` maps to `semantic_safe` and emits a deprecation marker. - `"auto"` maps to `adaptive`. Prefer the taxonomy values in new code. ## Adaptive tier (opt-in, guarded) `compression: "adaptive"` prunes eligible context under a strict safety net. It never fails a request — every gate below falls back to the `semantic_safe` subset: - **Never-touch rules** — code, JSON/tables, numbers, dates, IDs, URLs, emails, currency, citations, and named entities are pinned and survive byte-identical. - **Verbatim-intent guard** — if the instructions ask to quote/cite/translate exactly (en/pt/es), pruning is bypassed for all eligible context. - **Verify-and-retry** — after an adaptive response, quotes, citations, and JSON schemas are validated against the original context; on any failure the request is retried once with the uncompressed payload (a quality retry, not a model switch). - **40 ms budget** — the pruner races a timeout; an overrun falls back to `semantic_safe`. - **Circuit breaker** — a per-call-type breaker auto-disables adaptive if the verify failure rate exceeds a small threshold over 24 hours. Only `reference_context` and `history` are eligible for pruning; everything else is protected. ## Honest savings Compression savings are based on **provider-reported / invoice-reconciled** usage. Estimated removed tokens (a chars/4 diagnostic) are shown as a diagnostic only. The `ur.compression.savings_basis` field says which: - `provider_reported` — the request's usage was provider-reported and a real savings line was recorded in the [savings ledger](/docs/concepts/savings). - `estimated_diagnostic` — the figure is a diagnostic; no ledger line was recorded. ## Configuring compression Per call type you can set the adaptive prune ratio (`max_quality_risk`), `keep_last_n_turns`, a `no_compress_fields` list (JSONPath `messages.`), and the whitespace/dedupe toggles. ## Segment hints (`ur.segments`) You can label how the gateway segments a request by attaching `ur.segments` to the body. Each hint names a message `index` and its `kind` (`system_instructions` · `user_question` · `reference_context` · `few_shot_examples` · `tool_results` · `history`). A hint overrides the gateway's role/delimiter heuristics for that message; only `reference_context` and `history` are eligible for the adaptive tier — every other kind explicitly protects the message from any model-visible rewrite. ```json { "messages": [ { "role": "system", "content": "..." }, { "role": "user", "content": "\n\nQuestion: ..." } ], "ur": { "segments": [{ "index": 1, "kind": "reference_context", "start": 0, "end": 512 }] } } ``` The optional `start` / `end` character offsets refine a hint to a **span** within the message: the gateway splits the message at the boundary and applies the kind (and its eligibility) to `[start, end)` only — everything outside the span stays protected. Omit the offsets for a whole-message label (fully backward compatible). Span offsets never change how a request is grouped into a call type. These offsets are **raw** character offsets into your request's own message content. A span boundary that would split an emoji / surrogate pair is snapped outward toward protection, and an **invalid, inverted, or out-of-range span fails closed**: the hint is ignored, the message keeps its default classification (no content becomes lossy-eligible on a bad offset), and the request still succeeds. The learned segment maps described below use a **different coordinate system** (masked-template offsets), so a client must never copy a learned map's offsets into a request span. ### Learned segments are suggestions, never automatic ultrarouter observes how your prompts decompose across many requests and learns which segments are byte-stable (a cacheable instruction prefix) versus which vary per call. These learned segment maps are **evidence and suggestions only** — surfaced in your [audit readiness](/docs/concepts/audit) view. A learned hint is **never** applied to compression automatically: applying a model-visible transform without your consent would break the safety contract above. The only way a learned suggestion becomes an active declaration is an explicit, audited action you take to accept it, which writes **whole-message** declared hints for that call type. (Accepting is whole-message only — the learned map is in masked-template coordinates, which do not translate to raw request offsets, so declared span hints are intentionally not supported; use a per-request `ur.segments` span if you need one.) An accepted declaration only applies to a request whose message count matches the learned shape, so a positional hint never misfires when your prompt shape changes. Learning is passive; applying is always your call. --- # Discovery source: https://docs.ultrarouter.io/docs/concepts/discovery (https://docs.ultrarouter.io/docs/concepts/discovery.md) Flywheel does not start with a form. It starts with your traffic. The moment you change the base URL and key, ultrarouter watches the calls you already make and works toward two realisations: **what you are running** (the structural a-ha) and **which one workflow is worth learning from** (the semantic a-ha). You confirm one thing — what "it worked" means for that workflow — and nothing is learned until you do. Before any traffic arrives, discovery can start from what you have already published: your public website. When you give (or we infer) a site URL, we fetch and redact it and feed it to the discovery analyzer as **untrusted input** — its own neutralised data envelope, never an instruction — to propose the workflows you run and a provisional definition of good for each. At minute zero these arrive as proposals in the `collecting` state for you to confirm, edit or skip; a hostile website can steer nothing but a proposal you confirm. If we cannot reach an analyzer model, we make no proposal and say so — never a fabricated one. This page explains the two a-ha moments, the ladder a signal climbs before it can be trusted, and the single hard rule underneath all of it: **a proposed objective never teaches.** ## The two a-ha moments ### 1. The structural a-ha — "so *this* is what I'm running" Shortly after your traffic starts flowing, the **Learn** onboarding shows a picture of your account assembled from nothing but your requests — no configuration, no tagging. It has four parts: - **An inventory of your call types**, ranked by the share of spend each one accounts for over the window, with a request count and an archetype hypothesis (a *prior*, never a fact — shown as such, or omitted when it is below confidence). - **Contract-derived evaluator drafts** — starting points for how each call type could be checked, derived from its shape, offered for you to edit rather than imposed. - **Mechanical and next-turn defect counts, each with its `n`** — tool-call, schema and test failures the traffic already reveals, so the count is grounded and you can see how many observations it rests on. - **The outcome channels detected in your traffic, each with its rung** on the ladder below — so you can see, per channel, whether a real signal is available yet or only *possible*. The structural a-ha answers three questions at once: what am I spending on, what breaks, and what could I even measure? It is diagnostic. It never claims a workflow or an improvement — it is the ground the semantic a-ha is built on. ### 2. The semantic a-ha — "*this* is the workflow to improve, and here's the bundle" Once enough signal has accrued past the noise gate, ultrarouter proposes **derived workflow candidates** — groupings of your call types into a workflow, each call typed by its role (`intermediate` / `terminal` / `reviewer`) — and recommends one. For the recommended workflow it assembles a complete, inspectable **bundle**: - **One definition** of what "it worked" means for the workflow, plus **at most two alternatives** shown beside it, each flagged for whether it is teach-eligible. - **"Why we suggested this"** — the reason the recommendation exists, in plain words. - **An estimate of resolved outcomes per week** with its `n`, its interval and the window it covers — an *estimate*, labelled as one, never a headline number. - **The channel and the join** the objective needs — and, when the join is not yet wired, the **missing source**, named rather than hidden. - **A separate value axis**, so how *often* a workflow resolves is never confused with how *valuable* resolving it is. - **Provenance chips on every field** — `From your traffic`, `Drafted`, or `Yours` — reflecting where each value actually came from (see below). - **A one-sentence preview**: *Store X when Y happens; learn Z; apply it to calls W* — shown at confirmation and again on any material change, so you always see the whole proposal in one line before you accept it. A `collecting` state ("still gathering examples") or a `nothing learnable, because X` state is **honest output, not a bug** — but it is also a *failure* of the a-ha: the goal is to reach a real proposal, and when the product cannot, it says why plainly instead of dressing a diagnostic up as progress. ## Provenance is never dressed up Every field the proposal shows carries a chip reflecting its **real** origin: | Chip | Origin | Meaning | | --- | --- | --- | | `From your traffic` | structural | Derived deterministically from your calls. | | `Drafted` | drafted | An LLM *suggestion* — a starting point, never authoritative. | | `Yours` | customer | A value you entered or confirmed. | A `Drafted` field can **never** be re-labelled `From your traffic`. That is why accepting the top suggestion with one click grounds no external claim: a one-click acceptance of an LLM draft is recorded as a `Yours` confirmation of a `Drafted` value, not as evidence that the value was correct. Review actions — Confirm, Edit, Merge, Split, Decline — are **product telemetry**, never a correctness label; the only correctness signal comes from blind, pre-proposal intent capture and an adjudicated set, not from what you happened to click. ## The channel ladder An outcome channel earns trust one rung at a time, and **only receipts move it up**: ``` possible → configured → observed → qualified ``` - **possible** — the shape suggests this channel *could* exist. - **configured** — a source for it has been wired. - **observed** — real events have actually arrived through it. - **qualified** — enough real events have arrived, cleanly, to rely on. A schema, a tool name, or a code scan can suggest a channel is *possible*; none of them ever advances a rung. Availability is proven by events that arrived, not by evidence that they could. Learning eligibility for a given use is derived as the intersection of the rung with confirmation, consent, provenance and exposure — so a channel that is only `possible` teaches nothing, and even a `qualified` proxy channel is allowed to *rank* candidates but never to *teach*. ## Proposed never teaches This is the law the whole surface is built to protect. **A proposal is a draft. It is inert.** Nothing you see on the Learn screen — no derived workflow, no drafted objective, no suggested field — can influence a single request until you confirm it. It is not a runtime check that could be bypassed; it is a **type boundary** that makes the alternative impossible to express. The compiler that turns an objective into a live trigger accepts *only* a confirmed plan, and a confirmed plan has exactly one way to come into existence — the audited confirm service. A confirmed field's origin is typed to exclude `drafted`, so an LLM-drafted value is not even *assignable* into a confirmed plan; a proposed objective reaching the compiler is a compile error, not a refusal that has to fire at runtime. The confirm service reads the confirmed values you authorised — never the draft's proposed values — which is why editing any suggestion changes what you review but can never change what a confirmed plan teaches. Every learning consumer downstream keys on the **confirmed objective version**, read from the versioned config snapshot, and re-checks confirmation, consent, learning use, source binding and exposure at the moment it consumes — never the proposal rows. There is no path from a proposal to a lesson that skips your confirmation. ## The six card states On the **Flywheel** screen, one card tracks where a workflow's discovery stands, and every state offers exactly one next action. The state is **derived from the ledger and the plan rows — never a writable flag** — so it can only reflect reality: | State | What it means | | --- | --- | | `collecting` | Still gathering examples; no proposal yet. | | `proposal_ready` | A proposal exists and is waiting for your review. | | `confirmed_no_join` | You confirmed a workflow, but its outcome source is not wired yet. | | `resolving` | Confirmed and joined; real outcomes are arriving. | | `stale` | The confirmed plan has drifted from your current traffic — review the diff. | | `nothing_learnable` | Nothing worth learning was found, and the card says why. | When a confirmed plan drifts from the current traffic-derived draft, the card shows a per-field **diff** — the confirmed value beside the current one — with the verbs Accept, Keep mine, and Explain, plus the refreshed one-sentence preview. At most one material diff surfaces per workflow per week, and only one proposal is pending at a time, so the screen never floods you. ## What is live today, and what you will see next Live now: the discover → assist loop (a changed input digest produces one proposal and enqueues one bounded analysis pass, reserved against a spend cap before any model call), the plan projection and review routes ([`GET /v1/flywheel/plan`](/docs/api/flywheel), `POST /v1/flywheel/plan/:id/review`), the two a-ha payloads, the six card states, the diff, the coded Decline menu, and "describe it in your words" (bounded to a small number of attempts per day and a short length, with the limit shown to you rather than silently swallowing the input). Not yet wired — and described here as what you **will** see, never as a current state: live confirmation of a learned change against a holdout, the portable knowledge export you own, and the adoption of a lever into live traffic. Discovery finds the workflow and confirms the objective; turning a confirmed objective into a tested, live-confirmed improvement is the [correction-to-application-to-live loop](/docs/guides/correction-start), published as it ships. ## Next steps - Walk the flow end to end in the [workflow-first quickstart](/docs/guides/discovery-start). - Register [what "it worked" means](/docs/guides/flywheel-objectives) — the one thing the product refuses to infer. - Capture your [first reviewer correction](/docs/guides/correction-start) once you have a confirmed workflow. --- # Evals & auto-routing source: https://docs.ultrarouter.io/docs/concepts/evals (https://docs.ultrarouter.io/docs/concepts/evals.md) ultrarouter continuously evaluates how models perform on your real workloads and turns that into safe, reversible routing changes. ## Feedback and golden sets - Submit human feedback on any request with [`POST /v1/requests/:id/feedback`](/docs/api/feedback) — a thumbs up/down, tags, and an optional comment, scoped to the key's workspace. - Sampled requests form **golden sets** per call type (with optional PII redaction), the basis for offline evaluation. ## What the evaluator's objective grades Every evaluator suite has one **objective** — the axis a candidate is graded on and the axis a screening or migration verdict reports lift on. The suite records which axis it chose so a verdict always says what it graded. - When a call type's golden references come from **your own declared corrections** — a review you accepted unchanged, or one you edited to the right answer — the objective is **agreement with those references**: the label your corrections express. For a label-shaped call type (a classification label, a short enum, a compact single-answer object) that agreement is an exact or formatting-insensitive match through the same scorer family every suite uses. Agreement is measured on **exactly the label field(s) your objective names**, read from the contract or your learning plan — never guessed from the reference. The field comes from one of two places, and the axis records **which** (its `labelFieldOrigin`): - **`plan_confirmed`** — you confirmed the label field once in your learning plan. Discovery proposes it structurally (never by an LLM): among your contract's scalar leaf fields, the one your declared corrections change most often (edited finals vs what was served), with a field literally named `label` as the tie-break, and it carries its evidence (a count per candidate field). You confirm or edit that one field in the same confirm step. This is what makes the feature plug-and-play for a contract whose label field is named `intent`, `category` or `decision` — the product finds it and you approve it once. Changing the confirmed field starts a new evaluator epoch. - **`schema_convention`** — no plan field is confirmed but your contract has a property literally named `label` (for example the `label` of a `{situation, label}` response); that field is used. - **`null`** — neither is present. With more than one field in the reference the objective fails closed to comparing the whole reference (it never guesses a field); the axis records that it compared none and why. So an incidental field the objective does not name as the label — a `situation` or a `notes` a model phrases differently on every request — never counts against a right answer. An item whose reference carries **no value at the declared label** is treated as **not scorable** and excluded from the agreement rate — it is never counted as a match, so a missing label can never inflate accuracy; a label present in the reference but absent from a candidate's output is a plain disagreement. - A declared **JSON-schema or tool contract stays a hard gate** on top of the objective: an output that violates the schema or calls a tool wrongly always fails, regardless of the objective. The contract is never itself the objective — a candidate that is merely well-formed does not clear a run. - A golden set that carries no declared-correction references keeps the standard composite objective (schema, field and semantic scorers weighted per call-type family). This is why two candidates that both emit valid JSON can still be separated: the objective grades the *answer*, and the schema only gates *shape*. ## Evaluation runs An eval run scores candidate models for a call type against its golden set using a judge cascade. The results back **routing recommendations** — eval-supported suggestions to migrate a call type from its current model to a cheaper or better one. You can review recommendations via [`GET /v1/call-types/:id/recommendations`](/docs/api/call-types) or in the dashboard. ## Cost-per-task evidence Before any savings number, ultrarouter states **what evidence backs it**. A recommendation's **cost gate** and its projected savings rank on **provider-reported cost-per-task** — the actual per-item token consumption each candidate used on your golden set — never on per-token list price. Token consumption varies between models on identical work: a model that is cheaper per token can consume enough extra tokens to cost **more per task**. Each candidate's provider-reported token consumption is priced at your **net-effective rate** (your rate card when one applies, never list) and compared against the incumbent's on the same items, so a "cheaper per token but hungrier" candidate never masquerades as savings. Each recommendation carries an evidence **cost basis**: the provider-reported, replayable `measured` basis above, or the explicitly labeled `blended_price` per-token fallback used only when replayable usage was unavailable. The `measured` label is never applied to an estimate. On the replayable basis the evidence carries both sides' `$/task` and the `token_efficiency_ratio` (candidate tokens-per-task ÷ incumbent tokens-per-task); the same figures appear on the dashboard candidate leaderboard. **Projected monthly savings is an estimate, not realized cash.** It is your call type's trailing-30-day call volume × the provider-reported per-task net-cost delta, shown with its confidence interval; realized savings appear on your [ledger](/docs/concepts/savings) only after you migrate. ## Verdicts, power, and zero-judge A migration decision is a **tri-state verdict** on the frozen population, never a single score: - **`pass`** (migrate) — **both** a **quality non-inferiority** interval and a **cost-savings** interval clear their pre-registered bars. - **`fail`** (keep) — an established regression, a hard-scorer break, or a per-unit cost ceiling breached (the ceiling fails independently of quality). - **`inconclusive`** — anything else; the evidence does not decide either way. A `keep` or `inconclusive` outcome is first-class and carries a **named remediation** (e.g. capture more traffic, register an outcome). It is the instrument telling you the truth — never a failure to retry until it turns green. Before spending, a run publishes a **power advisory**. `underpowered` is true when the planning MDE (`mde_pp`) exceeds the registered margin (`margin_pp`) at the dataset's independent-cluster count (`cluster_n`); MDE is a **power diagnostic, never the verdict** — an underpowered dataset yields an `inconclusive`, never a false `pass`. When deterministic scorers — closed-schema exact-label checks and your own CI assertions — cover every applicable risk, the LLM judge is retired entirely: a **zero-judge** run, surfaced as `judge_disabled: true` (with `judge_worst_case_usd: 0`). Otherwise the composite **`trust_class`** (A / B / C) discloses how judge-dependent the quality signal was. The full vocabulary is in [Bench evidence & honesty](/docs/bench/concepts). ## Canary rollouts Approving a recommendation does not flip traffic all at once. A **canary** splits traffic between the current and candidate model, monitors guardrail metrics, and advances in stages. If a guardrail dips, the canary **rolls back automatically**; otherwise it promotes the candidate. A one-click manual rollback and a kill switch are always available. ## Commit-aware routing When you have provider spend **commitments**, routing is commit-aware: the marginal ranking accounts for committed capacity so recommendations and auto-routing help you burn down (not overshoot) a commitment, and renewal packs summarize utilization. The auto-routing engine is contract-stable today (`router: "auto"` returns `engine: "not_enabled"` where selection is not yet active for a workspace) and rolls out per workspace behind the canary machinery. --- # The Flywheel source: https://docs.ultrarouter.io/docs/concepts/flywheel (https://docs.ultrarouter.io/docs/concepts/flywheel.md) Your product already generates the signal that says whether each answer was any good: a reviewer edits the draft, a tool call fails, a user retries, a forecast resolves true or false against a filing months later. The **Flywheel** is the ledger that captures those outcomes, joins them to the answers that produced them, and — only once you confirm what "it worked" means — turns them into rules and examples that your app can consult, tested behind a standing holdout and exported back to you. This page is the map. It describes what is **built** and where the honest boundary sits, and it links to the concept and guide pages that go deep on each stage. The Flywheel is a **Phase-10 product**. The mechanisms below — the outcome ledger, the discovery projection, the confirm compiler, the learned-context lever, the live-confirmation estimator, the adoption lease and the training-ready export converter — are all built and covered by the guides. What is **not** yet a one-click button, a hosted URL or a shipped surface is called out where it belongs, never dressed up as done. `GET /v1/flywheel/status` reports its learning fields as explicitly **unavailable** while a workspace is still collecting — a collecting state never implies an improvement occurred. ## The loop The Flywheel is one loop with six stages. Each stage is a page you can read on its own. 1. **Discover** — from your traffic alone, ultrarouter surfaces one workflow worth learning and drafts what "it worked" could mean. Nothing is learned yet. See [Discovery](/docs/concepts/discovery). 2. **Declare the objective** — you confirm the one thing the product refuses to infer: the metric that resolves a workflow, its direction, source and window. See [Declare what "it worked" means](/docs/guides/flywheel-objectives). 3. **Capture outcomes** — the ledger records typed outcome events over six channels, instant or delayed, declared or inferred, and joins each to its artifact. See [Record outcomes](/docs/guides/outcomes) and [Capture your first correction](/docs/guides/correction-start). 4. **Distill, behind a holdout** — consented, teaching-eligible outcomes become redacted domain facts and a learned-context module, retrieved for opted-in requests and skipped for the sacred standing holdout. See [Holdout and lift](/docs/concepts/holdout-and-lift). 5. **Confirm live, then adopt** — a screened candidate clears your own application CI, then a randomized live contrast against a frozen baseline. Adoption is a revocable lease. See [Confirm a candidate in your own app](/docs/guides/application-ci). 6. **Export** — the whole thing becomes a training-ready dataset **you** own. Whether ultrarouter trains a model _for your workspace_ is a separate, governed decision (`train_allowed`), never a shared or cross-workspace model — and its switches are not built yet. See [The training-ready export](/docs/guides/training-export). You can also arrive sideways: [plan a model migration](/docs/guides/migration-start) imports your usage history and scopes a model swap, reusing the same ledger and evidence engines. ## The six outcome channels Every outcome the ledger records lands on exactly one **channel** — the closed set that answers "what kind of signal is this?" (doc 16 §5.1): | Channel | What it captures | Teaches? | | --- | --- | --- | | `mechanical` | Schema validity, tool-call success/failure, parse and test results — things the machine can check. | Yes (model-class tool errors teach; infra errors audit only). | | `correction` | A human's approved final vs the draft — an edit ratio, changed spans, a hash. | Yes. | | `resolution` | A claim resolved true / false / partial against the world, often much later. | Yes. | | `computable` | An oracle or counterfactual result — a deterministic check with a value. | Yes. | | `behavioral` | Accepted, retried, abandoned, escalated, copied, converted — what the user did next. | No — ranks only. | | `proxy` | Engagement, thumbs, dwell — soft signals. | No — ranks only. | The line between the two halves is a **law, not a preference**: **proxies rank; they never teach.** A `behavioral` or `proxy` row carries a server-assigned `learning_use` of `rank`, and calibration, distillation and the export refuse it at the schema boundary. Ranking a candidate is allowed; teaching a lesson from a thumbs-up is not. ## Declared ≠ inferred ≠ gateway The ledger also records **where** a signal came from, in a separate `source` column that is rolled up and displayed separately, never blended: - **declared** — your app told us, over the outcome API. - **inferred** — a versioned rule read it out of your agentic traffic (a `tool` error message, a user quoting-and-editing the prior turn). An inferred event never overrides a declared one for the same target and kind, and an inferred event from an unqualified rule can only be audited. - **gateway** — the gateway already knew (a JSON-schema failure, a streaming error), recorded best-effort and never billed. `source` is the server's to assign from your credential and the rule that fired; a caller-supplied `source`, `producer` or `learning_use` is a `400`, never a silent strip. That is why a captured signal always means exactly what its provenance says. ## The two laws that make it trustworthy Two guarantees are the reason a Flywheel claim is worth anything. Both are enforced in code, not by review. ### A proposed objective never teaches A discovery proposal is a draft. It is **inert**. It cannot influence a single request until you confirm it — and that is a **compiler guarantee**, the strongest true claim in these docs. The trigger compiler (`packages/core/src/flywheel/plan/confirm.ts`) accepts only a `ConfirmedPlan`, whose fields are typed `origin: Exclude`. An LLM-drafted value is **not assignable** into a confirmed plan; a proposed objective reaching the compiler is a compile error, not a runtime refusal that has to fire. Downstream, every learning consumer — rollup, distillation, the lever, the export, search — keys on the confirmed objective version and reads the compiled program from the versioned config snapshot, **never the proposal rows** (the TG1 "LAW E" grep gate allows exactly three files to name that table, and none of them is a learning consumer). There is no path from a proposal to a lesson that skips your confirmation. ### The standing holdout is sacred A fixed slice of your traffic is carved into a **standing holdout** and served the frozen baseline forever, so there is always an untouched comparison. **No lever code path ever reads holdout traffic except to skip it.** This is not a filter someone can forget: the exemplar index that feeds the learned-context lever is **arm-typed** — its container keys are `treated | pre_lever | imported`, so a `holdout` or `lever_holdout` candidate is a compile error, and the fail-closed bucketer throws rather than drop one silently. [Holdout and lift](/docs/concepts/holdout-and-lift) covers the arms and how lift is measured. ## Adoption is a lease, not a switch When a live confirmation shows a candidate is better, adopting it does **not** flip a permanent flag. Adoption is a **lease**: it needs live evidence and a scarce confirmation slot, and it **expires**. A background sweep runs every 15 minutes; an adopted lever whose lease has lapsed and not been renewed is reverted to the frozen baseline automatically. The failure mode of a forgotten adoption is "it stopped", never "it kept running unwatched." You renew or roll back on the Flywheel screen. ## Self-improve — one setting, three modes Each workflow (call type) has **one** self-improve setting with three modes, set on the call type's **Self-improve** card (or through `PATCH /v1/call-types/:id`): - **Off** — capture outcomes only. Nothing is proposed or adopted. Your ledger is still yours. - **Assist** _(the default when the flywheel is on)_ — evaluate usage, build the knowledge DB, propose what "good" means, test candidate improvements on your eval sets, and push the ones that **passed** to your Improvements inbox. The ones that failed are kept as internal knowledge ("tried, did not help") — visible on [Your knowledge DB](/docs/concepts/knowledge-db). A human adopts. - **Autopilot** — everything Assist does, **plus** it adopts a confirmed improvement automatically, within guardrails, after the live confirmation passes. You are notified. **The autopilot guardrail contract.** Autopilot adopts only when **every** guard holds: the live confirmation cleared the decision intent's **quality bar** (the lower confidence bound above the registered bar) **and** the **cost per successful task is within the budget you set** (no budget registered means autopilot will not auto-adopt — it fails closed), the A/A instrument is passing, the standing holdout and canary are in place, and a rollback path and an expiring lease exist. It applies **per surface** (knowledge injection today; model routing and hosted prompts are accepted but not yet wired) and only to **gateway-owned** modules — **code-owned prompts never auto-adopt**; they keep the pull-request path. Autopilot uses the exact same adoption path a human press uses (one engine, no second verdict), so canary, rollback, lease expiry and the sacred standing holdout are unchanged. Default is off, one toggle per call type. The confirmation read (`GET /v1/flywheel/candidates/:ref/confirmation`) shows the mode, who adopted (`system:autopilot` when the platform did), and — when autopilot did **not** fire — the named guard that stopped it, so nothing is hidden. ## What is billed, and what is not Capturing outcomes is **not** billed per event — artifact and outcome counts are operational counters, not charges, and there is no per-correction fee. What is billable is **bounded analysis** and approved **evaluation compute**, reserved and settled through the existing budget engine, with failed attempts counted in actual cost and retries never double-charged. `GET /v1/flywheel/status` shows the four quantities separated — tracked artifacts, accepted outcomes, analysis units, evaluation compute — so capture is never confused with spend. Test-mode keys (`ur_sk_test_*`) are captured but excluded from learning, invoicing and your allowance. ## The milestones, and where each is documented The Flywheel ships in stages, and each guide is honest about the boundary of its milestone: | Milestone | What you can do | Guide | | --- | --- | --- | | M0 | Import usage history and scope a model migration. | [Plan a model migration](/docs/guides/migration-start) | | M1 | Declare an objective; capture reviewer corrections joined to the original output; import history. | [Capture your first correction](/docs/guides/correction-start) · [Record outcomes](/docs/guides/outcomes) · [Import history](/docs/guides/import-history) | | M2 | Carry a screened candidate through your own application CI and the rollout gate. | [Confirm a candidate in your own app](/docs/guides/application-ci) | | M3 | Export a training-ready dataset you own. | [The training-ready export](/docs/guides/training-export) | ## What you will see next — named, not hidden Some surfaces a reader might reach for are **not built yet**, and this page names them rather than implying they exist: - **The knowledge API and the docs-MCP `search_knowledge` tool** — a way to browse the domain facts the distiller mints — are not shipped. When they land you will be able to read your workspace's derived facts (redacted statistics, never bodies) over the API and search them from an agent; today those facts feed the lever internally. - **Cross-tenant priors** — learning that borrows strength across workspaces — are **not** part of the Flywheel. A workspace's raw items never cross a workspace boundary; the only consented cross-tenant corpus is Bench, and it is governed separately. - **The one-click "start rollout" / "adopt" press** is a human dashboard action owned by a later packet; the rollout gate today **reports** readiness (`pressed` is always `false`) and never presses. - **Not on the hosted gateway yet** — the learning preview is off on the invite-only hosted gateway (`https://api.ultrarouter.io`), and there is no hosted MCP endpoint. Run the stack locally (`http://localhost:8080`) or self-host. ## Next steps - Read [Discovery](/docs/concepts/discovery) for how a workflow is found and the "proposed never teaches" law in full. - Read [Holdout and lift](/docs/concepts/holdout-and-lift) for the standing holdout, the exposure arms and how a real improvement is proven. - Walk [Record outcomes](/docs/guides/outcomes) end to end — objective, instant, delayed, batch, warehouse backfill and flush — in TypeScript, Python and curl. --- # Holdout and lift source: https://docs.ultrarouter.io/docs/concepts/holdout-and-lift (https://docs.ultrarouter.io/docs/concepts/holdout-and-lift.md) The Flywheel only claims an improvement when it can prove one, and proving one needs an untouched comparison. This page explains the **standing holdout** every workspace carries, the **exposure arms** a request can land in, and how a candidate becomes a **confirmed lift** — with the honesty rules that stop a passing test, a deploy or a merged pull request from quietly counting as a result. Live confirmation is a **built mechanism** (the estimator, the A/A gate, the frozen baseline and the lease are all in code), but pressing the button that opens a confirmation is a human dashboard action owned by a later packet. Where this page describes a live lift interval, it describes what the mechanism produces once a confirmation is running — never a number a fresh workspace already has. Until a confirmation has run, the learning surfaces read `unavailable` / `collecting`, and that is the honest state. ## The standing holdout is sacred A fixed fraction of your traffic is permanently assigned to a **standing holdout** and served the frozen baseline — no learned context, no lever — so there is always a clean baseline to measure against. The default is **10%** of traffic; an owner can adjust it within a `[5%, 30%]` band. Assignment is deterministic and cluster-stable. It is an **FNV-1a bucket** of `(workspace_id, cluster_key)`, where the cluster key is the first of these that the request has: 1. the artifact key (`ur.artifact` / `X-UR-Artifact`); 2. the trace id (`ur.trace_id`); 3. for an **untagged** request, a digest of the **conversation's opening**: every message up to and including the first `user` message; 4. the request id, only when the request has no `user` message at all. So every request of one artifact or trace shares the same arm, and an artifact never lands in a `mixed` state merely because two of its requests hashed differently. An untagged chat agent gets the same guarantee per conversation. A chat client resends the whole history on every turn, so every turn of one conversation carries the same opening and lands in the same arm. The system prompt part of the opening is masked, so a system prompt that embeds the current time does not split a conversation. The digest is used only to pick the bucket. It is never stored or logged. Two limits come with the untagged key. Two different conversations that open identically (same system prompt, same first message) share one arm, which makes the cluster coarser but never splits a conversation. A client that drops or rewrites the first user message mid-conversation starts a new cluster from that turn. When you need exact per-task clusters, send `ur.trace_id` (or an artifact key). A tagged request is keyed on its tag exactly as before, whatever its messages. `Math.random` is **forbidden** in the flywheel code; the bucket reuses the same seeded primitive the shadow sampler uses. The one rule that matters: **no lever code path ever reads holdout traffic except to skip it.** This is not a runtime filter someone can forget — it is a type boundary (see [The exemplar path is arm-typed](#the-exemplar-path-is-arm-typed) below). ## The exposure arms Every request is stamped, **at dispatch and immutably**, with an `exposure_arm` and an `exposure_epoch`. An artifact derives its arm from its requests. The arms are a closed set: | Arm | What it is | | --- | --- | | `holdout` | The sacred standing holdout — served the frozen baseline in every era. Carved first. | | `pre_lever` | The would-be-treated traffic when no lever has ever existed for the call type (nothing to treat yet). | | `lever_holdout` | A fixed **10%** of would-be-treated traffic carved while a confirmation is open — a first-class baseline arm with a frozen serving profile. | | `treated` | Baseline plus adopted levers, plus the candidate during a confirmation. | | `replay` | The hindcast arm — a backtest that never dispatches a live request. Structurally unreachable from the serving path; it may rank, never teach. | | `mixed` | An artifact whose requests disagree on their arm — **ineligible for lift**. | | `imported` | History brought in by the importer — never a live-lift contrast. | The `exposure_epoch` is recorded so that lift is **never pooled across epochs**: if the holdout fraction changes, membership can move, and comparing observations from two epochs would be comparing two different experiments. The code throws rather than combine them. ### The exemplar path is arm-typed The exemplar index that feeds the learned-context lever is arm-typed **at its definition**, not by a filter at each read site. Its container (`packages/core/src/flywheel/learned-context.ts`) keys on the teaching arms only — `treated | pre_lever | imported` — so a `holdout` or `lever_holdout` candidate is a **compile error**, unrepresentable rather than merely rejected. The defence-in-depth bucketer fails **closed**: handed a non-teaching arm it throws (`HoldoutFactNotRetrievableError`), because a silent drop would be indistinguishable from "there were none." A holdout leak is not something you have to test for at every call — it cannot be expressed. ## Rollups are computed per arm, never pooled Attribution and calibration are computed **per cell** — workspace × claim type × situation class × exposure arm × source — and arms are **never** merged. This too is enforced by construction: the rollup entry point takes an arm-keyed input and stamps each cell's arm from its bucket key, so two arms cannot collapse into one cell. Declared, inferred and imported sources are rolled **separately**. Every interval reuses the one statistics implementation (`packages/core/src/evals/stats.ts` — Wilson, cluster bootstrap); a second stats engine is forbidden. Below a minimum sample (`n < 20`) a derived statistic reads `insufficient_evidence` — never a computed-looking number — while raw counts and the censored share are always reported (an unresolved claim is never dropped). ## How a lift is proven A candidate does not become an improvement because a metric moved. It has to clear a sequence of **type-distinct** evidence, and each kind is kept separate so a weaker one can never be presented as a stronger one: 1. **Offline screening** — our replay on a disjoint split. A screening pass is not a live result. 2. **Application CI** — your own test/replay harness, in your own CI, binding real application code. A passed check is not a live confirmation, and a raw model-call comparison can never claim application coverage. 3. **Live confirmation** — a randomized contrast against the frozen baseline. The candidate is served to the `treated` arm; the `lever_holdout` slice is served the frozen baseline. **Only this can confirm improvement.** The [application-CI guide](/docs/guides/application-ci) walks stages 2 and 3 end to end; the code that keeps the three from blending is `staged-evidence.ts`. ### The quality bar: superiority A **quality-lift** claim requires the **lower bound of the confidence interval to be above zero** — a superiority verdict, not merely a point estimate that moved. The lift ledger renders **only** as an interval with its sample size. Before any lift is read, an **A/A false-positive check** (K = 20, false-positive rate ≤ 0.10) must pass; a violated A/A parks the instrument rather than reporting a result. The minimum detectable effect (MDE) is a **power diagnostic** — it tells you whether the experiment could have seen an effect — and is never itself the verdict. ### The cost bar: explicit decision intent Cost is judged under **named decision-intent contracts** (FLW-82), layered over the same evidence laws that govern Bench and the savings ledger. A cost-optimization verdict keeps its existing lower-task-cost requirement; a required migration or a product-improvement decision may use an explicit total-cost budget — but a budget pass is **never relabelled as savings**, and the frozen cost bound is never silently replaced by a customer budget. Cost and quality always refer to the **same** retained model invocation, over the complete execution envelope (tools, response format, generation parameters, streaming, provider policy) — dropping any of those makes the comparison ineligible. ## What a lift is not These are laws, and the product surfaces state them plainly: - **A merged PR is not measured improvement.** A code change that passes your CI is evidence your app still works, not evidence the model got better. - **A deploy under a running confirmation never becomes the result.** An application deploy or rollback that changes the bound baseline **invalidates** an open confirmation (a named `stale_head` / `app_config_changed` / `epoch_changed`) rather than crediting an unrelated change as lift. - **A `mixed` artifact is ineligible.** Its requests disagree on their arm, so it can never anchor a contrast. - **The holdout is never the treatment.** No adopted lever, no learned module, ever reaches a holdout request. Honest words follow from all of this: any claim of measured learning or improvement is stated with its **scope, its holdout interval and its `n`** nearby. A `collecting` or `insufficient_evidence` state does not imply a lift — it says, plainly, that none has been established yet. ## Next steps - Read [The Flywheel](/docs/concepts/flywheel) for how capture, confirmation and adoption fit together. - Walk [Confirm a candidate in your own app](/docs/guides/application-ci) for the rollout gate, live confirmation and the adoption lease. - See [The training-ready export](/docs/guides/training-export) for what leaves the holdout-protected system as a dataset you own. --- # Hosted prompts source: https://docs.ultrarouter.io/docs/concepts/hosted-prompts (https://docs.ultrarouter.io/docs/concepts/hosted-prompts.md) Your prompt is the instruction half of what a model sees. **Hosted prompts** let you store it in ultrarouter as a versioned object per call type, instead of hard-coding it in your application. A prompt we host is a prompt we can manage: it can be versioned, rolled back, and — later — proposed by the flywheel behind the same guardrails as every other change. A hosted prompt version is an **instruction** segment (trust class `authoritative`). It is a different object from the **learned** knowledge module (the capped data slot the Flywheel injects): two objects, one delivery channel. The prompt version carries the instructions your app wants followed; the learned module is reference data the model may consult. They never confound each other. ## The version object Each version is **immutable and append-only**: once created, its template text never changes. A "rollback" is re-activating an earlier version — the active pointer moves; a version body is never edited. Every version records: - a monotonic `version` number per call type, - the `template` text with `{{name}}` variable slots, - the declared `variables` (each `name` plus a `required` flag), - a `content_sha256` — the byte identity of the template (this is what shows up in the audit trail, never the template text itself), - its `source` (`human` for anything you write through the API). ## Managing versions All of these use your `config:write` scope (or a `config.write` session role); reads use `config:read`. A viewer role is read-only. ```http POST /v1/call-types/{id}/prompts # create a new (inactive) version GET /v1/call-types/{id}/prompts # list versions + the active pointer GET /v1/call-types/{id}/prompts/{version} # one version POST /v1/call-types/{id}/prompts/{version}/activate # activate (or roll back to) it POST /v1/call-types/{id}/prompts/deactivate # back to code-owned ``` Creating a version does not activate it. Activation writes a new call-type config version and moves the active pointer; because the pointer lives in the versioned config, the change is visible to the gateway and to preflight within one config cache window. Activating an **older** version is exactly how you roll back. Every mutation writes an audit event carrying the content sha (never the text). ## Two ways to deliver The active version is delivered one of two ways, and the choice is yours per call type: 1. **You fetch it.** `POST /v1/preflight` returns the active version as `prompt: { version, template, variables, sha256 }` when one is active (else `null`). The prompt is part of the preflight `ETag`, so a new activation moves the `ETag`. Prepend the template as your own leading system instruction. 2. **The gateway applies it.** Activate with `{ "gateway_apply": true }`. When you proxy a chat completion, the gateway renders the template and makes it the leading system instruction, **replacing** your request's leading system message(s). The learned knowledge module, if any, still lands after that stable prefix through the same one placement rule. Code-owned is always available: with no active version — or an active version that is not `gateway_apply` — a proxied request's own messages pass through untouched. ## Variables A template references `{{name}}` slots. When the gateway applies a hosted prompt, you supply values through the request's `ur.prompt_vars` map: ```json { "model": "openai/gpt-4o-mini", "messages": [{ "role": "user", "content": "..." }], "ur": { "prompt_vars": { "role": "support", "brand": "Acme" } } } ``` A missing **required** variable is a named, pre-dispatch `prompt_variables_missing` error that lists the names — never a silent blank. A missing **optional** variable renders as the empty string. ## Testing a draft before it goes live A version you create but do **not** activate is a **candidate**: your own draft, offered for your own evidence to test before it can serve a single request. A candidate is **screened** offline on the same path the Flywheel uses for every other change — nothing is fitted to your eval data (you wrote the instruction), so every eligible clustered item from your consented traffic is a screen item. The screen replays each item twice: once with the candidate's rendered instruction as the leading system message, once with the currently-active version (or your own messages, if the call type is code-owned). Both arms are scored by the call type's **qualified evaluator suite** — the same ruler that qualifies every other candidate — and the candidate parks at `screened` only when it measurably beats the baseline. Only **your own drafts** are candidates. The active version is the baseline it is measured against, never a candidate itself. A version whose `source` is `flywheel` is refused everywhere with a named reason: a *learned* prompt variant would come from a prompt proposer, which is not built — so no prompt is ever proposed or auto-written on your behalf in this release. A screened candidate appears in your **Improvements** inbox with its verdict and a "Your draft" chip, told apart from correction- and eval-derived candidates only by its provenance. The diff travels as ids and content hashes — the template text is readable only through the prompt read route above, never in a log or an audit diff. ```http GET /v1/flywheel/prompt-candidates # list your drafted candidates + verdicts POST /v1/flywheel/prompt-candidates/{runId}/decision # approve (activate) or reject ("did not help") ``` Listing candidates uses `config:read` (or a `flywheel.read` session role). Approving or rejecting is a **human step**: it needs a signed-in session with the `flywheel.write` role — never an API key. **Approve** activates that version through the same atomic path as `.../activate` above (a new config version, a new epoch), so the gateway and preflight serve your approved draft within one config cache window. **Reject** records the "did not help" reason and archives the candidate with its evidence. One lever family per call type may be *in confirmation* at a time: while the Flywheel is confirming a learned-knowledge change for a call type, a prompt candidate may still be screened but not approved until that confirmation closes — the inbox shows the named reason. --- # The Improvements inbox source: https://docs.ultrarouter.io/docs/concepts/improvements-inbox (https://docs.ultrarouter.io/docs/concepts/improvements-inbox.md) Everything the Flywheel produces — whatever the lever, whatever the source — arrives in **one place**: the **Improvements inbox** (`/improvements` in the dashboard). A prompt version, a model or routing change, an evaluator change: they are all _candidates_, and they all reach you here, in one list, with one lifecycle. There are **not** two inboxes — one for "from your evals" and one for "from your learning". That split would make you look in two places and would hide the fact that they are the same kind of thing: a proposed improvement waiting for your decision. Instead there is **one list**, and each row wears a **provenance chip** that tells you where it came from. ## What each candidate shows Every row carries the evidence you need to decide, kept honest and kept distinct: - **A provenance chip** — **"from your corrections"** or **"from your eval set"**. The chip is _derived_ from the candidate's own trigger evidence, never a label someone typed. A candidate proposed from the patterns in your corrections reads _from your corrections_; one proposed from your eval set reads _from your eval set_; a candidate whose source cannot be cleanly attributed says so rather than guessing. - **The offline result** — how the candidate did in offline screening, before any live traffic. Screening is a filter, not a verdict. - **The live status** — where the candidate sits in its live confirmation, if one has run. An offline screen and a live confirmation are **different evidence** and are shown separately; a screened candidate is never presented as confirmed. - **A link to the evidence** — the candidate's own run trace, so you can see the steps that produced it. - **Timestamps**, and, when the candidate's evidence has gone stale because the baseline changed underneath it, a plain note that it must be re-measured. ## Why a candidate was refused: the deciding axis A screen that _refuses_ a candidate always says **which axis decided it**, so you can tell three very different outcomes apart at a glance: - **"Helped, but over your cost budget."** The module improved quality — the quality interval clears the bar — but it costs more than the budget you stated for this decision. Quality passed; **cost** is the deciding axis. This is a real result: the improvement is genuine, it just is not free. - **"Did not help."** Quality is the deciding axis — the module did not clear the quality bar (or regressed it). Whatever it did to cost is beside the point. - **"Not enough evidence."** The screen was inconclusive — there was not enough signal to say either way. The candidate is not refused on a specific axis; it simply needs more evidence. Each refused candidate shows its **quality interval** and its **cost interval**, each labelled with the evidence tier it is (passed, failed, or inconclusive). These are the measured intervals from the screen — **never a savings or a quality claim**. The deciding axis, the intervals and the contract the screen was decided under are recorded on the candidate's own run trace, so the inbox and the API (`GET /v1/flywheel/candidates`) report exactly what the screen decided — nothing is recomputed after the fact. The contract you screen under sets the bar. A **product-improvement** contract asks for a genuine quality gain _within a stated cost budget_; the same module that is refused on cost under a tight budget can screen under a more generous one. The deciding axis makes the difference legible instead of collapsing every refusal into a single, unexplained "did not qualify". ## One lifecycle: approve or reject, with a reason You act on a candidate in one place, with one action, and a required reason: - **Approve** adopts the candidate — but only when a live confirmation has actually recorded a superior result. If it has not, the inbox tells you **why** the press is not yet available (for example, _not confirmed live yet_ or _already adopted_) rather than letting you adopt on a screening result alone. - **Reject** archives the candidate as **"did not help"**. Every decision is recorded as an **audited receipt with your coded reason** — it is never treated as a learning label. Every state you see is derived from the outcome ledger and the confirmation rows at read time; there is no writable "status" someone can flip out from under the evidence. ## The "did not help" rule When you reject a candidate, three things happen — and one thing deliberately does **not**: 1. **It is stored with its evidence.** The candidate, its run trace and your reason are kept whole. Nothing is deleted or hidden. 2. **It stays visible on the knowledge surface.** The archive on this screen links straight to the "tried and did not help" tab of [your knowledge DB](/docs/concepts/knowledge-db), which reads the very same refused candidates — the inbox links there rather than keeping a second copy. 3. **It is excluded from re-proposal.** The Flywheel's producer will not re-run a candidate you already refused; the refused version is fed into the same deduplication the producer already uses, so it is skipped before any model call. What does **not** happen: the evidence is never destroyed to make the refusal "stick". Excluding a candidate from re-proposal and preserving its record are two different things, and the inbox does both. ## Two goals, one surface Cutting cost is table stakes, so the product pursues **both** goals for **every** call type — without you asking. For each call type with enough traffic it computes two reads at once: - **Spend less here** — where a **cheaper model is enough**, from the Router/Evals recommendation evidence (the same migrate/keep decision the recommendation engine produces). - **Get better here** — where **quality can improve**, from the Flywheel's candidates and learning plans (the same evidence this inbox already shows). Each read is one of a small, closed set of states, and each carries its own evidence tier and a single next action: - **evidence** — a real result exists (a recommendation, or a screened/confirmed candidate): its source row, its tier, and, where the figure is a projection, an **estimate** label. A spend-less projection is an **estimated** monthly figure, clearly labelled and **never** shown as a realized saving; a screened candidate is shown as _screened_, never as confirmed. - **collecting** — not enough evidence yet, shown as _n of the floor_ using the **floor the owning engine already sets** (the eval funnel's minimum golden-set size for spend-less; the Flywheel's benchmarkable minimum for get-better). No new threshold is invented here. - **needs confirmation** — the Flywheel proposed a learning plan; the next action is to confirm it. - **keep incumbent** — the Router/Evals evidence says your current model is the best trade-off. This is a **result**, shown as one, not a gap. - **unavailable** — there is no qualified evidence yet, with a **named** reason. Missing evidence is never presented as "fine". Your **improvement goal** is reported alongside each get-better read. The default is **improve quality** (`product_improvement`); when you stated a different intent when you confirmed a plan, that intent is reported as-is and never overridden. A spend-less read is, by its nature, the cost-optimization decision. This surface is a **composition over the existing engines** — it computes no new statistic and ranks nothing new; it only reads what the recommendation and Flywheel engines already decided. The same composition is available over the API (`GET /v1/improvement-reads`). ### Two lenses, one surface In the dashboard the inbox shows these reads as **two lenses** over the same `/improvements` page — a segmented control, not two separate pages: - **Spend less here** lists, per workflow, where a **cheaper model is enough**. Every figure here is an **estimate** drawn only from your recommendations — it is labelled _Estimate — not yet realized_ and is **never** shown as, or added to, a realized saving on your ledger. The exact projected amount lives in the recommendation itself; the lens links you straight to it. - **Get better here** lists, per workflow, where **quality can improve**, with the Flywheel candidate's own evidence tier (screened offline, confirmed live, and so on). A screened candidate is shown as _screened_, never as confirmed. Both lenses are shown for **every** workflow with traffic, without you asking, and neither is hidden by default. Which lens leads follows your reported goal: with the default **improve quality**, _Get better here_ leads and the cheaper-model option is one tab away; if you stated a cost goal for a workflow, _Spend less here_ leads. You can switch lenses with the mouse or the arrow keys. An **estimate** is exactly that: a projection from your evaluation evidence, not a realized figure. It is always labelled, and the realized savings on your ledger are a separate, differently-styled number elsewhere — the two are never mixed. A workflow can honestly show **collecting** in either lens. In _Spend less here_ that means the evaluation set has not yet reached the eval funnel's minimum size, shown as _n of the floor_. In _Get better here_ it means outcomes are still being gathered before a quality change can be proposed — and because no per-workflow resolved-outcome count exists yet, the lens says _collecting evidence_ **without** inventing a number. Neither state claims an opportunity that is not there, and neither is presented as "nothing to do here". ## Where this sits The inbox is the **only** place a candidate is acted on. Your [knowledge DB](/docs/concepts/knowledge-db) is read-only and links here; the [Flywheel overview](/docs/concepts/flywheel) explains the loop that fills this list; and [holdout and lift](/docs/concepts/holdout-and-lift) explains why a live confirmation — not a passed check or a merged change — is what "it worked" requires before you can adopt. --- # Your knowledge DB source: https://docs.ultrarouter.io/docs/concepts/knowledge-db (https://docs.ultrarouter.io/docs/concepts/knowledge-db.md) The Flywheel distills your resolved outcomes into a **knowledge DB** — redacted domain facts about what tends to work for each of your workflows. **Your knowledge DB** (`/knowledge` in the dashboard) is where you see all of it. It is the **ownership** surface: everything on it belongs to you, and every number on it is re-derived at read time from your outcome ledger and the knowledge DB — never a value someone typed into a UI. This screen is **read-only by construction.** It has no writable toggle, no per-fact approve/reject button, and no setter of any kind. The only place a candidate is acted on is the Improvements inbox; this screen links there rather than duplicating it. ## What the screen shows **Overview tab** - **Counts and coverage** — how many situations the DB covers, how many patterns are **known**, and how many **coverage gaps** are open. Gaps are first-class: "what we do not know, and what would fill it" is a rendered state, not an empty list. - **What it taught** — the flat list of every known fact, each shown with the **evidence count `n`** and the 95% interval it was taught with, plus its window and lineage. A fact renders **with** its uncertainty, or it does not render. Every pattern is labelled an **observed association**, never a cause — a candidate that only reaches your calls through the Flywheel ratchet, never applied on its own. - **Injection policy per workflow** — for each workflow, whether knowledge injection is on or off, the per-module **token cap**, and the enabled kinds. This is shown **read-only** here; see [where it is set](#where-the-injection-policy-is-set). **Tried and did not help tab** - The candidates you **refused**, kept **with their evidence** instead of vanishing: the offline screening result, the live status, the **mandatory reason** the candidate was refused with, and when it was refused. A refused candidate is stored, not deleted — you can always see what was tried and why it did not help. A seeded demo workspace carries a persistent **"Example data"** badge, so demo content is never mistaken for your own. ## Why there are no per-fact approvals Approving facts one by one is theatre. A knowledge DB with hundreds of facts is not made safer by a hundred checkboxes — it is made unusable. So **approval is a policy, not a per-fact gate**: you decide, per workflow, whether knowledge injection is on, what token budget it may occupy, and which kinds are enabled. That policy governs every fact for that workflow at once, and it is the same setting the gateway reads when it decides what (if anything) to inject. Because the policy is the control, nothing on this screen needs a write. The numbers reconcile against the ledger and the knowledge DB directly, so the screen can never drift from what was actually distilled. ## Where the injection policy is set The injection policy is **read-only on this screen** and set on the workflow itself — the call type's **Self-improve** card (or `PATCH /v1/call-types/:id`), the same place the [self-improve mode](/docs/concepts/flywheel#self-improve--one-setting-three-modes) lives. Turning a workflow **off** captures outcomes without proposing or adopting anything; **assist** builds the knowledge DB and proposes tested candidates to your Improvements inbox; the candidates that did not pass are kept here under "tried and did not help." ## Next steps - Read [The Flywheel](/docs/concepts/flywheel) for the loop that produces these facts and the laws that keep every one honest. - Read [Holdout and lift](/docs/concepts/holdout-and-lift) for how a candidate is screened, confirmed live and adopted — and why a refused one is kept, not erased. ## Browse company knowledge `GET /v1/knowledge` searches the current workspace's stored lessons and supported derived facts. Use a `config:read` or `config:write` key, or a member session with `X-UR-Workspace`. Both knowledge and corrections consent must remain enabled. Reads make no embedding or model calls. | Query | Meaning | | --- | --- | | `q` | Literal lexical substring, at most 200 characters. | | `kind` | `lesson` or `fact`; omit for both. | | `call_type_id` | Restrict to one workflow. | | `version_id` | Restrict to one lesson version; combine with `include_history=true` for older versions. | | `include_history` | Default `false`: current facts and incumbent/pending candidate lessons. `true` includes history and retired lessons. | | `limit` | 1–100, default 50. | | `cursor` | The previous `next_cursor`, with every filter and limit unchanged. | The response contains `data`, `has_more`, `next_cursor` and `snapshot_at`. Current candidate lessons can have `current=true` and `active=false`; they have not become the incumbent. `active` identifies the incumbent's nonretired lesson, not a guarantee that each request receives it. Workflow disable/pause and serving eligibility remain governed by learning controls. Neither publication nor admission establishes efficacy. `GET /v1/knowledge/{id}` reads one stable item: `fact:` or `lesson::`. URL-encode the full ID. Foreign, erased and unknown items return 404. Lessons include their text, atomic conditions/exceptions/tool scope, recipe and metadata-only citations. `evidence_count` and `evidence_truncated` disclose the 100-reference bound. Citation availability says whether that same-workspace/workflow metadata record exists; it does not promise a retained raw transcript or prove a lesson's correctness. Fact lineage availability is explicitly `not_checked`. Facts expose only supported kind descriptions, structured scope, sample counts, rates, intervals and source references. Raw fact bodies, correction example text and raw episode content are excluded. Pages use stable ID ordering. Source changes invalidate a continuation with `409 knowledge_cursor_stale`, as does a cursor older than 15 minutes; restart the search. A malformed cursor or changed filters return `400 knowledge_cursor_invalid`. `snapshot_at` records when the traversal began, not a durable database snapshot. Each page rechecks current consent and deletion rules. Existing correction-body retention does not expire derived lessons by age; revocation and deletion still remove their content through existing controls. ## Export company knowledge content `GET /v1/knowledge/export` accepts the same filters and returns `ur.company_knowledge/v1`. This separate, audited operation requires an owner/admin session, 2FA, `X-UR-Workspace` and explicit `training_export` consent in addition to knowledge/corrections consent. API keys cannot perform this export. The existing `GET /v1/flywheel/knowledge/export` metadata/statistics bundle remains available and unchanged. The content export includes `selection` (normalized filters), `data`, an explicit `exclusions` list, `content_hash`, `complete`, `truncated`, `next_cursor`, `generated_at` and `snapshot_at`. A complete filtered selection is not the whole workspace. Any page that omits preceding or following matching items is marked `complete=false` and `truncated=true`; a final continuation still represents a fragment even with no next cursor. Continue using the same export route and unchanged selection to obtain further pages. The hash covers the schema, workspace, selection, exact projected data, omissions and completeness flags; generated clocks and cursor are excluded. This is portable retained knowledge, not frontier model weights, an import format or an automatic activation request. Model training remains separate future work. Search and export use the management path, outside the request-time lesson-serving budget. Returned content and version-state lookups are page-bounded. The existing visibility/drop metadata and revision aggregates can still grow with workspace history; this release does not promise constant-time searches or a new search index. Ordinary uncited episode traffic does not invalidate cursors; changed or deleted cited evidence metadata does. --- # Export your knowledge DB source: https://docs.ultrarouter.io/docs/concepts/knowledge-export (https://docs.ultrarouter.io/docs/concepts/knowledge-export.md) The Flywheel distills your resolved outcomes into a **knowledge DB**: redacted domain facts about your workflows, each carried **with its uncertainty** — an evidence count, an interval, a window, and lineage back to your own outcome events. **Export** takes that whole DB out as a single portable file you own. The bundle is **yours to take.** It is a projection of your own append-only outcome ledger and knowledge store — never a second source of truth. It is a **different object** from the training-ready dataset export (which builds SFT/preference examples for _you_ to train on): the knowledge bundle is the DB itself, for portability and audit. Training a model from your content is a separate, governed path that runs only _for your own workspace_. ## What the bundle contains `GET /v1/flywheel/knowledge/export` returns one JSON object (`schema: ur.knowledge_bundle/v1`) with these sections: - **`facts`** — the model-class failure-mode facts. - **`exemplars`** — the clustered, redacted correction patterns (how your reviewers fix outputs). - **`priors`** — the resolution base-rate and calibration priors, each with its rate and interval. - **`process_patterns`** — the observed process-pattern associations per workflow × situation. - **`modules`** — the learned modules and their **lift history**: the epoch, the live-confirmation verdict, and the recorded treated − holdout interval. - **`lineage`** — one row per exported item, linking it back to the outcome-event ids and content hash it derives from. Every item carries ids, content hashes and **derived statistics only** — never a raw prompt or output body. You join an item back to the fuller, retained view you already own (the `/knowledge` screen) by its `fact_id` or `content_hash`. ## Current facts vs history Your knowledge store is append-only: when a fact changes, a new row is written and the old one is kept. The bundle keeps **all of it** — the history is yours — but it is never ambiguous. Every fact carries `current: true|false`, and a superseded predecessor carries `superseded_by` (the id of the fact that replaced it), decided by the **same read-time rule** the runtime uses to serve the live fact. Each section's counts are split into `current` and `history`, so you always know which facts are live and which are kept for the record. ## It is deterministic and content-addressed The bundle is addressed by a `bundle_hash` computed over its canonical content. The generation timestamp (`generated_at`) is **excluded** from that hash, so exporting the same data twice produces a **byte-identical** bundle and a stable `bundle_hash` — you can diff two exports, or verify an archived one, without the clock getting in the way. ## Consent, revocation and retention Export is **consent- and retention-bound**, exactly like every other derived surface: - A workspace that has not enabled the **training-export consent** (FLW-25) gets a named refusal, `EXPORT_CONSENT_REVOKED` — the whole bundle is refused, never partially emitted. - A **revoked** fact (a consent-withdrawal tombstone) and a **suppressed** fact (an active retrieval-suppression window) are **dropped** from every section and from lineage, through the same drop rules the knowledge-pack export uses. - Items past retention are simply absent. ## Who can export it Exporting the whole knowledge DB is an **owner or admin** action (a viewer is refused). The request is authenticated with your dashboard session, every export is **audited** (the `bundle_hash` and per-section counts are recorded — never any content), and the structured log carries ids, hashes and counts only. ## From the dashboard Your **Knowledge** page (`/knowledge`) has one **Export knowledge** action in the header. It downloads the bundle as a JSON file named after its `bundle_hash`. If your workspace has not enabled the training-export consent, the action tells you so instead of producing an empty file. ## Export vs. training We **build** this bundle for you; the export path itself invokes no fine-tuning or trainer API — a grep-level guarantee (`TST-FLW-TRAINING-GOVERNED`) that the same scan applies to the training-ready dataset export. Training a model from your content is a **separate, governed decision**: it happens only _for your own workspace_, only behind the six-conjunct `train_allowed` authorization, and never produces a shared or cross-workspace model — and enabling this export never authorizes it (its switches and the `train_allowed` enforcement are built; a governed trainer is not, TRN0b). The bundle is for you: to keep, to inspect, to carry to another tool, or to feed to your own training run if you choose to. ## Stored lesson content and search The separate [company knowledge API](/docs/concepts/knowledge-db#browse-company-knowledge) provides lexical search, version-specific lesson detail and metadata-only evidence references. Its explicit session-only content export, `GET /v1/knowledge/export`, uses `ur.company_knowledge/v1`; it includes stored lesson text and a safe derived fact projection. The metadata bundle described above is unchanged. Every content export states its filters, omissions and pagination completeness. Neither format exports frontier model weights. --- # The five products source: https://docs.ultrarouter.io/docs/concepts/products (https://docs.ultrarouter.io/docs/concepts/products.md) ultrarouter is **five capabilities on one ledger and one gate**, not five products you have to wire up. You pick a job — migrate a model, or build a data flywheel — and the capabilities sit behind it. This page names all five, says what each is for and how far each is built, and links to where each one is documented in depth. ## Two ways in Migration and Flywheel are **parallel** offers. Start with either; neither is a prerequisite for the other. - **Plan a model migration** — import your usage history, scope a swap, and get a decision pack with the config diff and rollout checklist. Built on the paired [Scoring](/docs/bench) comparison, the qualified [evaluators](/docs/concepts/evals) and the auto-rollback [canary](/docs/concepts/routing). See [Plan a model migration](/docs/guides/migration-start). - **Build your data flywheel** — connect the outcomes your product already generates, confirm what "it worked" means for one workflow, and carry a learned improvement through your own CI and a live comparison you own. See [The Flywheel](/docs/concepts/flywheel). The first complete correction-to-application-to-live learning loop is the primary new milestone — but a migration sale is never required to use or evaluate the Flywheel. ## The five capabilities | Capability | What it is | Availability | | --- | --- | --- | | **[Router](/docs/concepts/routing)** | One OpenAI/OpenRouter-compatible URL across providers: BYOK, fallbacks, spend caps, streaming and tools, with a documented config-precedence chain. | Available today | | **[Evals](/docs/concepts/evals)** | Evaluators generated from your traffic and proved to catch defects, gating every change from PR to canary to rollback. | Available today | | **[Scoring](/docs/bench)** | Which model holds your quality bar at what cost per successful task — a paired, signed, refusable comparison (ultrarouter Bench). | Available today | | **[Flywheel](/docs/concepts/flywheel)** | The outcome ledger: what happened next to every answer, learned into rules and examples behind a holdout, training-ready and customer-owned. | Phase 10 — the mechanisms are built; see the guides for each milestone's boundary | | **Context** | Deterministic context assembly with a manifest: modules, contracts, budgets and lazily loaded SOPs. The gateway is a witness of the manifest, never its author. | Phase 10 — documented separately as it ships | Every availability label is honest, and it is repeated wherever a capability appears. Any improvement claim we publish carries its scope, sample size and confidence interval, or it does not appear. ## Router The drop-in proxy. Point your existing OpenAI or OpenRouter SDK at the gateway base URL, keep your `messages`, and you are done: `POST /v1/chat/completions` (streaming and non-streaming), `POST /v1/embeddings`, `GET /v1/models`. Behind that one URL are [BYOK provider keys](/docs/guides/providers-byok), [pinned / multi-offer / auto routing](/docs/concepts/routing), a [compression safety taxonomy](/docs/concepts/compression) (off by default), and a [savings ledger](/docs/concepts/savings) that computes honest, procurement-defensible numbers. Every request records which layer decided each field. ## Evals Continuous, judge-backed evaluation. Evaluators are drafted from the shape of your traffic, qualified against a labeled set, and then used to gate changes — a candidate model or prompt has to clear them before a [canary](/docs/concepts/routing) promotes it, and a dip rolls it back automatically. See [Evals](/docs/concepts/evals). ## Scoring — ultrarouter Bench A paired benchmark you can defend. Tag your own dev/test traffic with a suite, freeze an immutable weighted dataset, run your incumbent paired against challengers, and read an immutable evidence report — quality and all-in cost intervals, a trust class, and a keep / migrate / inconclusive verdict — then gate CI on it. The honesty rules (dataset cards, power plans, the estimate-vs-provider-reported distinction) are first-class. Start at the [Bench quickstart](/docs/bench/quickstart). ## Flywheel The newest capability, and the one this documentation set completes. The Flywheel captures typed outcome events over [six channels](/docs/concepts/flywheel), joins each to the answer that produced it, and — only after you confirm what "it worked" means — turns consented, teaching-eligible outcomes into rules and examples your app can consult, tested behind a [standing holdout](/docs/concepts/holdout-and-lift) and exported back to you as a [training-ready dataset](/docs/guides/training-export). We train models only _for your own workspace_ — never a shared or cross-workspace one. The switches and the `train_allowed` enforcement are built; what is not built is a governed trainer, so no training run executes yet. Two laws make it trustworthy: **a proposed objective never teaches** (a compiler guarantee) and **the holdout is sacred** (no lever ever reads it). Read [The Flywheel](/docs/concepts/flywheel) for the loop and the boundaries. ## Context A pure context runtime: deterministic assembly of context **modules** under **contracts** and token **budgets**, with SOP catalogs that load lazily instead of pasting everything into every prompt. It is the controlled-delivery path for the rules and examples the Flywheel learns. The gateway is a **witness** of the `ur.manifest` a request carries, never its author, and the exit function is pure — you can adopt the runtime in one file and leave it in one file. Context has its own concept and guide pages, published as that surface ships; this page names it so the set of five is complete and honest. ## One ledger, one gate What makes these five a system rather than a bundle is that they share **one outcome ledger** and **one evidence gate**. A Scoring comparison, an Evals qualification, a Flywheel live confirmation and a migration verdict all reference the same immutable cost and quality lineage — there is no parallel recomputation with a different cost basis, and a figure shown by a gate, a leaderboard, a report, a statement or an export always traces to the same evidence. Your data stays tenant-isolated and fully exportable, and raw items never cross a workspace; we train models only **for your own workspace**. ## Next steps - [The Flywheel](/docs/concepts/flywheel) — the outcome loop, the channels and the laws. - [Holdout and lift](/docs/concepts/holdout-and-lift) — how a real improvement is proven. - [Quickstart](/docs/quickstart) — make your first Router call in five minutes. - [For agents](/docs/for-agents) — the machine-first entry point. --- # Routing modes source: https://docs.ultrarouter.io/docs/concepts/routing (https://docs.ultrarouter.io/docs/concepts/routing.md) A logical model id (e.g. `openai/gpt-5.2`) can be served by several providers (OpenRouter-style **offers**). ultrarouter resolves your model chain into an ordered list of concrete provider offers and executes them with deadlines. ## The router field Set `router` on the request (or per call type): - **`off`** (default) / **`pinned`** — use the resolved model + its offers in order; no cross-model substitution. - **`auto`** / **`candidates`** — auto-routing. The response `ur.routing` carries `engine: "not_enabled"` where auto-selection is not yet active for your workspace, so the contract is stable for SDKs while the eval-driven engine rolls out per workspace via [canary](/docs/concepts/evals). Auto-selection only ever adopts a model a passing eval [verdict](/docs/concepts/evals#verdicts-power-and-zero-judge) has cleared, then rolls it out behind an auto-rollback canary — a downgrade in autonomy (a per-request `router` can only lower it, never raise it). ## Fallbacks and offers - `model` is the primary; `models: [...]` are ordered fallbacks. The chain is the outer loop; a model's provider offers are the inner loop. - Offers are ordered by `provider.sort`: `price` (default, per input-token cost) or `throughput`. The suffixes `:nitro` (throughput) and `:floor` (price) set the sort per model. - **Provider preferences** (`provider.order`, `ignore`, `quantizations`, `data_collection: "deny"`, `allow_fallbacks: false`) filter and order candidates. Every rejected candidate is recorded with a machine-readable reason. - A **capability guard** skips any offer that lacks a capability your request needs (tools, `json_schema`, vision, embeddings). In a multi-offer chain a capable sibling still serves the request; only the incapable offer is skipped (with a reason). - **Capability refusal, never a silent downgrade.** If _no_ offer for your requested model can honour a capability you explicitly asked for — most commonly a `response_format: json_schema` pinned to a model that does not support structured output — the gateway refuses the request **before dispatch** with the GW-70 `capability_unsupported` error (HTTP 400, not retryable), naming the missing capability. It will never quietly downgrade your `json_schema` to `json_object` or drop it, and never substitute a different model — your output contract is honoured or the request is refused with the real reason. If a provider itself rejects an unsupported `response_format` at dispatch (a raw upstream 400), that failure is mapped onto the same `capability_unsupported` code, so logs and clients see the real cause instead of a generic upstream error. - **Unpriced offers are never routed.** An offer with no usable price — a model a live catalog sync discovered that nobody has priced yet, or a custom provider registered without `pricing` — is removed before planning, because the gateway cannot compute what a call to it costs and would otherwise record it as `$0`. A sibling offer that is priced still serves the request, and the removed offer is listed under `unpriced_refused` in the routing trail. If the model you asked for has **no** priced offer, the request is refused before dispatch with the GW-70 `model_unpriced` error (HTTP 403, not retryable). It is never silently served by a fallback model. A price of `0` that you declared on a custom provider counts as a price. - **Retired and renamed provider models.** The catalog is checked against each provider's live model list. When a provider stops listing a model, that offer stops being routed: a request that names it gets `not_found` if no other offer serves it, and a sibling offer still serves the model in a multi-offer chain. When a provider renames a model and the catalog records the new id, the offer moves to the new id at the same price, so the model stays routable under its logical id. ## Deadlines `ur.timeout_ms` is the total budget (default 30 s non-stream / 60 s stream, clamped `[1s, 300s]`) and spans all attempts. Each attempt is capped at `min(remaining_total, attempt_cap)`. A **retryable** provider error advances to the next offer, then the next model; a **non-retryable** error surfaces immediately. Streaming may fall back only **before the first content byte**; once bytes flow, a failure is a terminal `event: error` frame — never a silent model switch. ## The routing trail Every request records the full trail — mode, sort, each attempt (model, provider, outcome, latency), the skipped candidates with reasons, whether a fallback was used, and the per-field config sources. Fetch it after the fact with [`GET /v1/generation`](/docs/api/generation). --- # Savings methodology source: https://docs.ultrarouter.io/docs/concepts/savings (https://docs.ultrarouter.io/docs/concepts/savings.md) ultrarouter reports savings you can defend in front of procurement. The numbers come from an **append-only ledger**, not an estimate, and the math never claims a discount that is not ours. ## The ledger Every served request appends one `gateway_call` ledger row in the same transaction as the request log: - **list cost** — the authoritative hot-path cost at published (list) prices. - **effective cost** — the cost after your **rate card**, if one applies (an exact model match is preferred over a provider-wide card, time-window bounded). - **savings** — `list − effective`. Compression and cache discounts append their own savings rows (`compression_saving`, `cache_saving`) when they actually saved, as pure savings lines (no double counting with the call cost). Error rows (no served model) are not metered. ## Net-effective pricing The net price a customer faces is **their rate card, else list**. Savings math never uses raw list against a customer's own negotiated discount — so a statement never claims your own discount as ours (doc 07 §5). The monthly statement lines are: - **baseline** = Σ gateway-call list cost, - **effective** = Σ gateway-call effective cost, - **total savings** = Σ every event's savings (rate-card delta + compression + cache + migration deltas). ## Statements A monthly worker aggregates each period's ledger into one **immutable** savings snapshot; a restatement is a new superseding row, never an edit. You can also generate the current statement on demand and download it as CSV or JSON. The dashboard `/savings` screen shows this month's running savings, the methodology explainer, and the immutable statements list. Rate cards and commitments are entered in the dashboard (manual or CSV import); CSV --- # Evidence status source: https://docs.ultrarouter.io/docs/evidence-status (https://docs.ultrarouter.io/docs/evidence-status.md) A working API call says nothing about whether learning helps. This page keeps three questions apart, and answers each one only as far as the evidence goes. 1. **Is it available?** The feature exists and you can call it on the local stack. 2. **Is it operationally verified?** An ordinary client connects, reports outcomes and takes part in the whole loop through public interfaces only, proven on the real HTTP path against a live gateway, database and workers. 3. **Is an improvement demonstrated?** Two separate claims: - **useful adaptation**: a learned configuration beats the right no-learning baseline on outcomes or efficiency; - **continued improvement**: a later learned configuration beats an earlier learned one on independent evaluation data. A successful call, a synthetic example, a published set of lessons or a growing lesson count is never evidence for the third question. ## Status Last reviewed 2026-10-29. | Capability | Available | Operationally verified | Improvement demonstrated | | --- | --- | --- | --- | | Connect an OpenAI-compatible agent (base URL and key) | Yes, local stack | Yes | Not applicable | | Report an outcome from a trusted source | Yes | Yes | Not applicable | | Outcome receipts, including outcomes reported before the request is stored | Yes, local stack (preview) | Integration and HTTP end-to-end tests | Not applicable | | Self-serve enable, pause, disable and rollback | Yes, local stack (preview) | Integration and HTTP end-to-end tests | Not applicable | | Randomized candidate evaluation before replacing the active version | Yes, local stack (preview) | Integration and HTTP end-to-end tests | Not applicable | | The whole loop driven by an ordinary client through public interfaces only | Yes, local stack (preview) | Yes, bounded local example with live model calls | Not applicable | | Useful adaptation | — | — | Development findings on one public benchmark only; not demonstrated for your agent | | Continued improvement | — | — | **Not demonstrated**: the pre-declared test failed | The hosted gateway (`https://api.ultrarouter.io`) is invite-only and does not offer learning yet, and the SDKs are not on a package registry. The learning preview is available as source on each public SDK repository's `feat/learning-loop` branch. Build and install that branch's package using [Get started](/docs/get-started). Source availability does not establish learning effectiveness. On 2026-10-29, the public-client example passed all 26 operational checks over 20 episodes, using the local subscription gateway. It exercised the packaged TypeScript and Python clients, management MCP, outcome receipts, scheduled reflection, randomized evaluation and lifecycle controls. The automatic evaluation retained the incumbent because it did not establish improvement. During the comparison, the candidate served only its randomly assigned share of eligible episodes. Manual activation was used only to exercise rollback; the run ended with learning disabled and no pending reflection. This verifies that bounded local workflow, not production scale or learning effectiveness. After tightening episode assignment and the handling of missing outcomes, a fresh live check passed 25 checks over 19 episodes, including both SDKs, randomized evaluation and all lifecycle controls. That invocation failed its MCP check because the separate client had not been built. Nine read-only MCP checks subsequently passed against the updated runtime using a deterministic fixture. These are separate pieces of evidence; the failed invocation remains on record. The later live evaluation also retained the incumbent. ## What we can say about improvement **Useful adaptation.** In development studies on one public tool-calling benchmark, the same agent did better with learned lessons than with none. Those studies followed repeated experimentation on that benchmark's development scenarios, so they are development findings. They are not a measurement of what learning will do for your agent, and not a guarantee. **Continued improvement.** We declared in advance a test of whether lessons learned later beat lessons learned earlier, with a minimum margin. The result fell short of that margin, so the test failed. We keep that failure on record, and we do not claim continued improvement. **What would change this.** An evaluation on data the learner has not been tuned on, with the decision rule fixed before any outcome is read, and every comparison also run against a simpler one-shot learner so that iterative learning has to earn its complexity. Negative and inconclusive results are kept. Research checkpoint tests activate their frozen lessons immediately. They do not establish the business effect of the default customer policy, which evaluates candidates before replacing the active version and can spend time waiting or retain the incumbent. That effect needs its own application comparison, with a trustworthy outcome signal and startup included. A simpler one-shot learner can be a useful product result if it offers comparable quality at materially lower cost; the current comparison does not isolate recursion as the cause of any difference. Early-to-late gains would support a claim over those checkpoints, not indefinite improvement. ## Reading the evidence for your own agent Your own call type's evaluations are the evidence that matters for you. Each one records the rule it was judged by (fixed before it started), the two arms, the interval and the decision. Set `UR_CALL_TYPE_ID` to the `ct_...` ID returned when you register or list call types: ```bash curl -sS "$UR_BASE_URL/v1/call-types/${UR_CALL_TYPE_ID:?set the call type ID}/learning/evaluations" \ -H "Authorization: Bearer $UR_API_KEY" ``` A candidate that does not win keeps the current version. "Inconclusive" means the evidence did not establish the required advantage; uncertainty or missing outcomes can produce that result. It does not establish zero benefit or equivalence, and it is not a reason to retry until the comparison turns green. ## What "recursive" means Outcome, adaptation, evaluation, deployment, new outcome, and around again. ultrarouter adapts the context it serves your agent from the outcomes you report; it never changes model weights. ## Methods The development studies above are recorded in the ultrarouter repository under `docs-internal/results/learn-api2/`; the continued-improvement test is the study named B3 there. Those records are internal; ask us if you need them. The bounded operational run and its failed earlier attempts are recorded under `docs-internal/plug-and-play/stage2-evidence/2026-10-29/`. --- # For agents source: https://docs.ultrarouter.io/docs/for-agents (https://docs.ultrarouter.io/docs/for-agents.md) This documentation is built to be discovered, ingested, and acted on by a coding agent without a human. Everything below is a stable, machine-readable surface. **Fastest path:** paste [the AGENTS.md snippet](/docs/agents-snippet) into your repo's `AGENTS.md` / `CLAUDE.md` and an agent can onboard ultrarouter, add the MCP server, and wire the Bench CI gate from that one block. The rest of this page is the detail behind it. ## Which product? Choose when / not when ultrarouter is [five capabilities on one ledger](/docs/concepts/products), not five things to wire up. Each block below says when a capability fits your app and when it does not. Every claim here is verifiable from the linked pages through the docs MCP (`get_doc`/`search_docs`). ### Router **Choose it when** you are building **any OpenAI-compatible app** and want one URL across providers: bring-your-own-key, fallbacks, spend caps, streaming and tools, with a `ur` block on every response recording which layer decided each field. Point your existing OpenAI or OpenRouter client at the gateway base URL and keep your `messages`. **Not when** you are locked to a single provider's non-OpenAI-shaped endpoint and never plan to compare or fail over — a proxy buys you nothing there. **Verify:** [Router](/docs/concepts/routing) · [chat completions](/docs/api/chat) · [migrate from OpenAI](/docs/migrate/from-openai). ### Evals **Choose it when** you are **any app shipping prompt/model changes** and want a defect to fail CI before a user sees it: evaluators drafted from your traffic, qualified against a labelled set, then gating a candidate from PR to canary to automatic rollback. **Not when** you have no repeatable task to judge — a one-off script with no recurring prompt or model change has nothing for an evaluator to guard. **Verify:** [Evals](/docs/concepts/evals) · [call types API](/docs/api/call-types) · [application CI](/docs/guides/application-ci). ### Scoring **Choose it when** you have **any bill worth a paired test**: tag your own dev/test traffic as a suite, freeze an immutable weighted dataset, run your incumbent paired against challengers, and read a signed keep / migrate / inconclusive report with quality and cost intervals you can gate CI on. A `keep` or `inconclusive` verdict is the instrument working. **Not when** the spend is too small to justify a run, or you have no traffic to freeze into a dataset yet — capture some first. **Verify:** [Scoring](/docs/bench) · [Bench API](/docs/api/bench) · [savings ledger](/docs/concepts/savings). ### Flywheel **Choose it when** your app has **a checkable shape or a downstream act — NOT engagement-only products**: an answer that is later accepted, corrected, resolved or acted on, so an outcome can be joined to it, learned into rules and examples behind a standing holdout, and exported back to you. **Not when** the only signal is engagement (time-on-page, clicks) with no notion of a right answer — there is nothing checkable to learn from, and a proposed objective never teaches until a live confirmation proves it. **Verify:** [The Flywheel](/docs/concepts/flywheel) · [record outcomes](/docs/guides/outcomes) · [holdout and lift](/docs/concepts/holdout-and-lift). ### Context **Choose it when** you are building **apps with more than ~10 SOPs or recurring context bloat**: a deterministic runtime assembles context modules under contracts and budgets, and a lazy SOP catalog loads bodies only at the gated phase boundary instead of pasting every procedure into every prompt. The exit is pure — adopt in one file, leave in one file. **Not when** your prompt is small and stable — a single system prompt with a couple of examples does not need a manifest. **Verify:** [The context runtime](/docs/guides/context-runtime) · [the five products](/docs/concepts/products) · [The Flywheel](/docs/concepts/flywheel). ## MCP servers There are two stdio MCP servers, and neither one routes your agent host's own traffic through ultrarouter: the **documentation MCP** (`ultrarouter-docs`, below, no key) and the authenticated **management MCP** (`ultrarouter`: connection check, call types, learning status and controls, outcome receipts — with your key). Install, packaging for Claude Code and Codex, and uninstall for both: [MCP servers for Claude Code and Codex](/docs/guides/mcp-servers). The documentation MCP (`@ultrarouter/docs-mcp`) lets your agent query the docs and the API contract directly. It is not on npm yet; run it from an ultrarouter checkout: ```bash pnpm --filter @ultrarouter/docs-mcp... build claude mcp add ultrarouter-docs -- node "$PWD/packages/docs-mcp/dist/bin.js" ``` Or add it to any MCP client config: ```json { "mcpServers": { "ultrarouter-docs": { "command": "node", "args": ["/path/to/ultrarouter/packages/docs-mcp/dist/bin.js"] } } } ``` Tools: - `search_docs(query, limit?)` — ranked full-text search across the docs. - `get_doc(path)` — the clean markdown of one page (by URL or slug). - `get_openapi(format?)` — the generated OpenAPI 3.0 spec (JSON or YAML). - `example_request(endpoint)` — a runnable curl for one endpoint (by `operationId` or `METHOD /path`). Secrets are always placeholders. ## Text surfaces - [`/llms.txt`](/llms.txt) — an index of every page with one-line descriptions and canonical URLs. - [`/llms-full.txt`](/llms-full.txt) — the full corpus concatenated, with a structured "for agents" quickstart (env, curl, TS, Python, error taxonomy, rate limits) in the first 2,000 tokens. - **`.md`** — every page is available as clean markdown at its own URL plus `.md` (e.g. `/docs/quickstart.md`), and via `Accept: text/markdown` content negotiation. ## Machine contract - [`/openapi.yaml`](/openapi.yaml) and [`/openapi.json`](/openapi.json) — the versioned OpenAPI 3.0 spec, CORS-open. Generated from the gateway's zod route schemas and drift-gated in CI, so it always matches the running API. - [`/sitemap.xml`](/sitemap.xml) and [`/robots.txt`](/robots.txt) — AI crawlers are explicitly allowed. ## The minimum to make a call Set `UR_BASE_URL` (your gateway, e.g. `http://localhost:8080`) and `UR_API_KEY` (an `ur_sk_live_…` / `ur_sk_test_…` key sent as `Authorization: Bearer $UR_API_KEY`). ```bash curl -sS "$UR_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"openai/gpt-5-mini","messages":[{"role":"user","content":"hi"}]}' ``` Any OpenAI Chat Completions request body works. The response adds a `ur` block (request id, model + provider served, routing trail, compression, cost). See the [error taxonomy](/docs/api) for the stable failure codes. ## Zero to a verdict The exact calls to go from a first request to a defensible keep/migrate/inconclusive verdict and a CI gate — using only shipped `/v1/bench/*` routes. Capture needs a **test-mode** key (`ur_sk_test_…`); a live key with a bench signal is refused (a consent boundary). These are stateful, so they are plain snippets (not the executed reachability checks in the [Bench quickstart](/docs/bench/quickstart)). **1. First request → captured suite.** Add one header to the calls you already make. The serve path is unchanged; the gateway captures the execution envelope off the hot path and echoes `ur.bench.capture_status: "queued"` (never `captured: true`). ```bash curl -sS "$UR_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -H "X-UR-Bench-Suite: checkout-agent" \ -d '{"model":"openai/gpt-5-mini","call_type":"refund-reply", "messages":[{"role":"user","content":"Draft a refund reply."}]}' ``` **2. Freeze → an immutable dataset.** Snapshot the confirmed captures into a content-addressed, per-call-type weighted dataset. `max_items` caps the per-call-type item count (default 500; over-cap uniques drop keeping the highest frequency). ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/checkout-agent/freeze" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"max_items": 500}' # scope: config:write ``` **3. Power check → don't spend on a run that can't decide.** Do a `dry_run` with `require_power: true`. Read each disposition's `power` block — `cluster_n`, `required_clusters`, `mde_pp` vs `margin_pp`, and `underpowered` — and the run-total `judge_worst_case_usd` (surfaced additively, never folded into `estimate_usd`). With `require_power: true` an `underpowered` call type becomes `not_runnable(underpowered)` and is excluded from the estimate/reservation before any budget is reserved. ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/checkout-agent/runs" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"max_cost_usd": 3, "dry_run": true, "require_power": true}' # scope: bench:run # → dispositions[].power {cluster_n, required_clusters, mde_pp, margin_pp, underpowered} # + estimate_usd + judge_worst_case_usd (an unaffordable judge phase is refused here). ``` **4. Run → paired dispatch.** Drop `dry_run` to dispatch a real paired run (incumbent re-invoked against challengers) within the same `max_cost_usd` umbrella. Live paid dispatch is gated by the `bench.paid_dispatch` kill switch (default ON); when OFF it is refused with `atomic_spend_guard_unavailable` before any reservation or provider call. ```bash curl -sS -X POST "$UR_BASE_URL/v1/bench/suites/checkout-agent/runs" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"max_cost_usd": 3, "require_power": true}' # returns bench_run_id (br_*) ``` **5. Verdict → the immutable report.** The report is content-addressed (byte-stable on re-read): per call type × arm, cost views labeled by basis (`$/request` vs `$/task`), the pinned trust class, and the dual-interval keep / migrate / inconclusive verdict with a named remediation. `?format=bundle` is offline-verifiable with `pnpm evidence:verify`. ```bash curl -sS "$UR_BASE_URL/v1/bench/runs/$RUN_ID/report" \ -H "Authorization: Bearer $UR_API_KEY" ``` **6. CI gate.** Pin a completed run as the suite baseline, then fail a PR when the compare verdict is not a pass (exit `0` = pass, `2` = fail/inconclusive/any API error — fail closed). ```bash ur bench baseline $RUN_ID # pin once ur bench run checkout-agent --gate --baseline bench:main --json ``` A `keep` or `inconclusive` verdict is the instrument working, not a failure — never retry a bench until it turns green, and treat any savings figure as an estimate or a projection until it is reconciled on the [savings ledger](/docs/concepts/savings). See the [Bench quickstart](/docs/bench/quickstart) and [evidence & honesty](/docs/bench/concepts) for the full contract. --- # Get started source: https://docs.ultrarouter.io/docs/get-started (https://docs.ultrarouter.io/docs/get-started.md) Plug-and-play learning is a preview. The whole loop runs on the local stack today: outcome receipts, learned candidates evaluated against the version you run, and pause, disable and rollback. Whether learning improves a given agent is **not yet demonstrated**. Read the [evidence status](/docs/evidence-status) before you rely on it. ultrarouter sits between your agent and its models. You tell it whether each finished task succeeded, from a source you trust. It turns those outcomes into candidate adaptations, evaluates each candidate against the version you run today, deploys it only when it wins, and keeps learning from the new outcomes. That cycle is what "recursive" means here: outcome, adaptation, evaluation, deployment, new outcome. It changes the context ultrarouter serves your agent, never the model's weights. ## The fast path: hand it to your coding agent Pick your coding agent, your language and how you want to connect, then copy the prompt. It tells the agent to inspect your project, reuse its configuration, install the SDK from a local build, wire a trusted outcome, run one verified episode and confirm its receipt. It asks you only for sign-in, consent and keys, and it never contains a key. The prompts are generated from versioned setup recipes. Agents can read every recipe, with exact commands, environment variable names and expected output, at [`/setup-recipes.json`](/setup-recipes.json) (schema `ur.setup-recipes/1`). The differences between Claude Code and Codex are on [Set up with Claude Code or Codex](/docs/guides/agent-integrations), and the MCP servers and plugins on [MCP servers for Claude Code and Codex](/docs/guides/mcp-servers). ## Before you start - **The local stack.** Learning is not on the hosted service yet (the hosted gateway at `https://api.ultrarouter.io` is invite-only and serves routing and compression), so this guide runs on the local stack. In your checkout of the ultrarouter repository, `pnpm dev:e2e` starts the gateway on `http://localhost:8080` and the dashboard on `http://localhost:3000`. `curl -sS http://localhost:8080/readyz` answers `"status":"ready"` when it is up. - **Your account.** Sign up at `http://localhost:3000/signup` and create a workspace. Email is not delivered on the local stack: the verification link is in the test outbox at `http://localhost:8080/auth/test/outbox?to=`. Enrol two-factor authentication when asked; an owner needs it to mint keys and to change learning. - **Workspace Learning opt-in.** As the owner, choose **Allow learning** on the call type's dashboard **Learning** page or in the **Learn** onboarding flow. It grants both **corrections consent** to store the task and the agent's steps and **knowledge consent** to use what it learns; corrections consent alone is insufficient. The equivalent request is `PUT /v1/workspaces/{id}/self-learning` with `{"enabled":true}` from a signed-in owner session, not a bearer API key. Revoking corrections consent stops learning and deletes what was stored. - **A model your workspace owns.** Under **Providers & BYOK**, choose **Add key** for the provider whose models your agent and the learning reflection will use, or add a custom provider with its own key ([provider keys](/docs/guides/providers-byok)). Learning only ever calls a model the workspace owns, never a platform key, and there is no paid fallback. - **BYOK funding.** A new workspace starts on prepaid UR credits, which never pay your own keys. Choose BYOK funding once from your signed-in session with [`POST /v1/workspaces//billing/funding`](/docs/guides/providers-byok#funding-choose-byok-to-use-your-own-keys) and `{"mode":"byok","allowPlatformFallback":false,"requestKey":"<16 to 128 letters, digits, - or _>"}`. Until then a request only your own key can serve is refused with HTTP 403 `permission`. - **An API key.** In Settings, under API keys, mint a key with the `gateway:invoke`, `outcomes:write` and `config:read` scopes. Add `config:write` only if a script or your coding agent should turn learning on, pause it or roll it back. Put the gateway URL and the key in the environment of the shell that runs your app and your coding agent, and never in source control. `read -rs` keeps the key out of your terminal and shell history: ```bash export UR_BASE_URL="http://localhost:8080" read -rs UR_API_KEY && export UR_API_KEY ``` Start your coding agent after this, so it inherits both. Codex removes variables whose names contain `KEY`, `SECRET` or `TOKEN` from the commands it runs, so start it with this override (it lets every such variable through, so start it from a shell that holds only the secrets this project needs): ```bash codex -c shell_environment_policy.ignore_default_excludes=true ``` ## Connect your agent Learning is per **call type**, so name your agent's traffic: send an `X-UR-Call-Type` header (or a `call_type` field) on every request. The gateway creates the call type the first time a request names it. If one task takes several model calls, send the same `X-UR-Artifact` header (your task or run id) on each, so they form one episode. Every response carries an `x-ur-request-id` header; keep the one from the task's **last** call, because the outcome is reported on it. With your existing OpenAI client, change only its configuration: ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: `${process.env.UR_BASE_URL}/v1`, apiKey: process.env.UR_API_KEY, defaultHeaders: { "X-UR-Call-Type": "my-agent" }, }); // Every model call of one task carries the same task id; keep the last request id. const { data: completion, response } = await client.chat.completions .create({ model, messages }, { headers: { "X-UR-Artifact": taskId } }) .withResponse(); const lastRequestId = response.headers.get("x-ur-request-id"); ``` With your existing OpenAI client, change only its configuration: ```python import os from openai import OpenAI client = OpenAI( base_url=f"{os.environ['UR_BASE_URL']}/v1", api_key=os.environ["UR_API_KEY"], default_headers={"X-UR-Call-Type": "my-agent"}, ) # Every model call of one task carries the same task id; keep the last request id. raw = client.chat.completions.with_raw_response.create( model=model, messages=messages, extra_headers={"X-UR-Artifact": task_id} ) last_request_id = raw.headers.get("x-ur-request-id") completion = raw.parse() ``` Neither SDK is on a package registry. Clone the public **`feat/learning-loop` branch** to get the learning preview (0.1.0), build it, and install the resulting tarball or wheel. The default `main` branch does not carry this learning preview. Exact file names are in [`/setup-recipes.json`](/setup-recipes.json). ```bash # TypeScript: clone the learning branch and build its tarball, then install the file git clone --branch feat/learning-loop --single-branch https://github.com/lucastonon/ultrarouter-sdk-typescript ../ultrarouter-sdk-typescript (cd ../ultrarouter-sdk-typescript && npm ci && npm run build && npm pack) npm install ../ultrarouter-sdk-typescript/ultrarouter-sdk-0.1.0.tgz # Python: clone the learning branch and build its wheel, then install the file git clone --branch feat/learning-loop --single-branch https://github.com/lucastonon/ultrarouter-sdk-python ../ultrarouter-sdk-python (cd ../ultrarouter-sdk-python && uv build) pip install ../ultrarouter-sdk-python/dist/ultrarouter-0.1.0-py3-none-any.whl ``` The SDK reads `UR_BASE_URL` and `UR_API_KEY` and takes `call_type` on every request. See the [TypeScript SDK](/docs/guides/sdk-typescript) and [Python SDK](/docs/guides/sdk-python) guides. The copy-to-agent prompt above has a complete runnable example for each language. ## Wire a trusted outcome signal When a task is finished, report **once** whether it succeeded, on the task's last request id. The source matters more than anything else you set up: | Your source | What to send (`channel` / `kind`) | Teaches? | | --- | --- | --- | | A verifier: tests, a schema or exact-match check | `computable` / `oracle_pass` or `oracle_fail` | Yes | | A business rule your code checks | `computable` / `oracle_pass` or `oracle_fail` | Yes | | A delayed webhook (a ticket resolved, a payment cleared) | `resolution` / `resolved_true` or `resolved_false` | Yes | | A human review | `correction` / `accepted_unchanged` or `rejected` | Yes | | Self-rating: the agent or a model grading its own answer, or a thumbs-up | `proxy` / `thumbs` | Never | | You do not know | send nothing | — | Self-rating is a weaker signal. It is recorded and can help rank, but it never teaches. An HTTP 200, or a tool call that returned, is not task success. Missing feedback stays unknown. ```bash curl -sS -X POST "$UR_BASE_URL/v1/requests/$REQUEST_ID/outcomes" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{"channel":"computable","kind":"oracle_pass","source_event_id":""}' ``` The answer (HTTP 202) has a `status` and a durable `receipt`: - `accepted`: recorded now. `duplicate`: a retry of an outcome already recorded, so nothing changes. - `pending`: the gateway has not recorded the request yet, which is normal right after the response. The outcome is stored and applied when the request lands; the receipt tells you when. `source_event_id` makes the report idempotent: a retry with the same id never counts twice (the same id with a different body is a 409). A delayed outcome works the same way, days later, on the stored request id. More recording modes (batch, warehouse backfill, the durable queue) are in [Record outcomes](/docs/guides/outcomes). Check the receipt (`ocr_…`) by its id, or every receipt of a request: ```bash curl -sS "$UR_BASE_URL/v1/outcome-receipts/$RECEIPT_ID" -H "Authorization: Bearer $UR_API_KEY" curl -sS "$UR_BASE_URL/v1/requests/$REQUEST_ID/outcome-receipts" -H "Authorization: Bearer $UR_API_KEY" ``` A receipt moves from `pending` to `applied`. `rejected`, `failed` and `expired` are final and carry a `reason`; `expired` with `request_not_found` means no request with that id appeared in your workspace within 15 minutes. Its `episode` says whether the outcome reached the episode it belongs to. ## Turn on learning Learning is off until you turn it on for a call type, and the learning routes take the call type's **id** (`ct_…`), not its slug. Look the id up once the first request has created the call type: ```bash curl -sS "$UR_BASE_URL/v1/call-types" -H "Authorization: Bearer $UR_API_KEY" \ | jq -r '.data[] | select(.slug == "my-agent") | .id' export UR_CALL_TYPE_ID="" ``` Then turn it on, with a key that has `config:write` (or as an owner or admin): ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/$UR_CALL_TYPE_ID/learning/enable" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{"reflection_model":"","promotion":{"mode":"evaluate"}}' ``` The route checks what [Before you start](#before-you-start) set up and names what is missing: | Answer | Cause | Fix | | --- | --- | --- | | `409` `corrections_consent_required` | Corrections or knowledge consent is off. | The owner chooses **Allow learning** in the dashboard or uses the session-authenticated `PUT /v1/workspaces/{id}/self-learning` with `{"enabled":true}`. | | `400` `reflection_model_not_workspace_owned` | The model is not served by a key your workspace stored or by one of its custom providers (or prepaid billing funds with credits). | Add a key under Providers & BYOK, or pick a model one of your keys serves. | | `403` | The key lacks `config:write`, or you are not an owner or admin. | Use an owner or admin session, or a key with `config:write`. | | `404` | No call type with that id in your workspace. | Use the `ct_…` id from `GET /v1/call-types`. | `reflection_model` is required. `promotion`, `lessons_mode`, `lessons_k` and `recipe` are optional. With the promotion mode `evaluate`, the default, a learned candidate is never activated just because it was produced. Each episode is assigned the candidate or the current version, and the candidate becomes active only if it wins under a rule fixed before the evaluation starts. A negative or inconclusive result keeps the current version. ## Watch, pause or roll back Read the status. `state` is `off` until learning is on, then `collecting` (waiting for evidence), `learning` (a reflection is due or an evaluation runs), `paused`, `disabled`, or `unavailable`, `quota_exhausted` or `budget_exhausted` with a `reason`. `reflection.provider` says whether the reflection model is usable and, if not, why. `evidence` separates episodes that arrived, were verified, and were used by the active version, plus the backlog; `outcome_receipts` counts receipts by state. ```bash curl -sS "$UR_BASE_URL/v1/call-types/$UR_CALL_TYPE_ID/learning" -H "Authorization: Bearer $UR_API_KEY" curl -sS "$UR_BASE_URL/v1/call-types/$UR_CALL_TYPE_ID/learning/evaluations" -H "Authorization: Bearer $UR_API_KEY" curl -sS "$UR_BASE_URL/v1/call-types/$UR_CALL_TYPE_ID/learning/versions" -H "Authorization: Bearer $UR_API_KEY" ``` Each evaluation shows the rule it was judged by, the two arms, the interval and the decision. An inconclusive evaluation is the system working, not a failure. Every control is audited and needs `config:write` (or an owner or admin): ```bash # Pause: stop reflecting and evaluating, keep serving the active version (resume undoes it) curl -sS -X POST "$UR_BASE_URL/v1/call-types/$UR_CALL_TYPE_ID/learning/pause" -H "Authorization: Bearer $UR_API_KEY" curl -sS -X POST "$UR_BASE_URL/v1/call-types/$UR_CALL_TYPE_ID/learning/resume" -H "Authorization: Bearer $UR_API_KEY" # Disable: stop serving learned lessons (a running evaluation stops too) curl -sS -X POST "$UR_BASE_URL/v1/call-types/$UR_CALL_TYPE_ID/learning/disable" -H "Authorization: Bearer $UR_API_KEY" # Roll back: point the call type at an earlier version (omit version_id for the previous active one) curl -sS -X POST "$UR_BASE_URL/v1/call-types/$UR_CALL_TYPE_ID/learning/rollback" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{"version_id":""}' ``` ## Remove it entirely 1. Disable learning for the call type (above). 2. Point your client back at its previous base URL and remove the outcome report. 3. If you added the MCP servers: `ultrarouter-mcp uninstall --host claude --with-docs` or `ultrarouter-mcp uninstall --host codex --with-docs` (the plugins have their own uninstall, on [MCP servers for Claude Code and Codex](/docs/guides/mcp-servers)). 4. Revoke the API key in the dashboard Settings. Revoking corrections consent deletes the stored episodes. ## What learning does not do - It never changes model weights. It adapts the context ultrarouter serves your agent. - It never learns from self-rating, a click or the gateway's own signals; only the outcomes you report from a trusted source teach. - It never shares what it learned with another workspace. - It never promises an improvement. See the [evidence status](/docs/evidence-status) for what is and is not demonstrated. --- # Set up with Claude Code or Codex source: https://docs.ultrarouter.io/docs/guides/agent-integrations (https://docs.ultrarouter.io/docs/guides/agent-integrations.md) ultrarouter is set up by your coding agent, from a prompt you copy. This page collects what is specific to each agent: the setup prompts and how Claude Code and Codex differ. Installing the MCP servers and the plugins is covered on [MCP servers for Claude Code and Codex](/docs/guides/mcp-servers). ## Copy a setup prompt Every prompt is generated from a versioned setup recipe. The full set, with exact commands, environment variable names, expected output and the generated prompt for each agent and language, is at [`/setup-recipes.json`](/setup-recipes.json) (schema `ur.setup-recipes/1`). The walkthrough behind them is [Get started](/docs/get-started). ## Claude Code and Codex: what differs | | Claude Code | Codex | | --- | --- | --- | | Project instructions | `CLAUDE.md` | `AGENTS.md` | | Install the MCP servers | `ultrarouter-mcp install --host claude --with-docs` | `ultrarouter-mcp install --host codex --with-docs` | | Where the installer writes | `~/.claude.json` (user scope) | `~/.codex/config.toml` | | Check it | `claude mcp get ultrarouter` | `codex mcp get ultrarouter` | | Remove it | `ultrarouter-mcp uninstall --host claude --with-docs` | `ultrarouter-mcp uninstall --host codex --with-docs` | | Seeing `UR_API_KEY` in shell commands | Inherits the environment it was started from | Removes variables named `*KEY*`, `*SECRET*` or `*TOKEN*` from commands by default | | Network access | Shell commands may need your approval | The sandbox may block it, localhost included; approve the command, never turn the sandbox off | The installer comes with the management MCP, built from your ultrarouter checkout; neither MCP server is on a package registry yet. [MCP servers for Claude Code and Codex](/docs/guides/mcp-servers) has the build, the plugin route for each agent, the keys the servers read and the uninstall steps. For Codex, start (or resume) the session with the override that lets `UR_API_KEY` through to the commands it runs. It lets every such variable through, so start it from a shell that holds only the secrets this project needs: ```bash codex -c shell_environment_policy.ignore_default_excludes=true codex resume --last -c shell_environment_policy.ignore_default_excludes=true ``` Export `UR_BASE_URL` and `UR_API_KEY` before you start either agent; to keep a conversation, restart it with `claude --continue` or the `codex resume` form above. Never paste a key into the conversation. ## What the agent can and cannot do The prompts ask your agent to do everything except what needs you: signing up or in, email verification, two-factor enrolment, accepting terms, the workspace Learning opt-in (corrections and knowledge consent), provider keys and minting the ultrarouter API keys. They ask before turning learning on or using its controls. The management MCP can read learning status and receipts and can pause, disable or roll back, but it has no tool that reports an outcome: an agent can never mark a task as succeeded. Only your trusted outcome source does that. ## Read next - [Get started](/docs/get-started): the same setup by hand, step by step. - [MCP servers for Claude Code and Codex](/docs/guides/mcp-servers): install, plugins and uninstall. - [Evidence status](/docs/evidence-status): what is and is not demonstrated about learning. - [For agents](/docs/for-agents): `llms.txt`, markdown pages and the OpenAPI contract. --- # The AI reviewer (automated teacher) source: https://docs.ultrarouter.io/docs/guides/ai-reviewer (https://docs.ultrarouter.io/docs/guides/ai-reviewer.md) Most of the flywheel learns from a **human** correction: a reviewer edits a draft, and that edit becomes the lesson. The **AI reviewer** adds a second, automated source of the same signal. It reads one of your drafts, judges it against **its own request**, and returns a verdict — `approve`, `edit` (with a corrected version) or `reject`. ultrarouter then records that verdict as a **machine-verified correction**, exactly where a human correction would land. It is an **independent verifier, not a new teaching path**. The reviewer never decides on its own that a lesson is good enough to use. Its corrections are **audit-only** until it earns a qualification against your own human truth (see below), and even then they stay `inferred` — a reviewer verdict never becomes a `declared` (human) one, and the reviewer never reads a holdout draft. ## What it judges The reviewer works from a **frozen, versioned, hashed rubric** (verifier id `ai_review`, version `v1`). The rubric is generic, so it fits any workflow. It asks, of each draft: 1. Does the draft satisfy every explicit instruction in the request? 2. Does it stay faithful to the source material supplied in the request? 3. Does it contain any unsupported or fabricated claim? 4. Does it read as publishable as-is? 5. When your call type declares an **objective**, does the draft meet it? The rubric text is pinned by a content hash, so a changed prompt can never pass as `v1`. The reviewer's answer is parsed strictly: a verdict from the closed set, reason codes from a closed list, and — only for `edit` — a bounded corrected draft. Anything malformed is dropped by name and teaches nothing. ## How a verdict is recorded Each verdict becomes an `inferred` **correction** outcome event (`accepted_unchanged` for `approve`, `edited` for `edit`, `rejected` for `reject`). For an `approve` or `edit`, ultrarouter also mints a **verification receipt** (migration 0126) that binds the verdict to the confirmed objective, the exact target request and version, a decision-prefix hash and the replacement body (the draft itself for `approve`, the corrected text for `edit`). A `reject` records the event only — there is no corrected output to teach from. The reviewer is driven **through the gateway with a dedicated, capped key**, so its spend is metered and bounded by that key's limit; the job stops cleanly when the limit is reached. ## The qualification gate — why corrections stay audit-only first A machine-verified correction may **only** teach once the `(ai_review, v1)` verifier is **qualified** in ultrarouter's rule-qualification ledger (FLW-21). Qualification is earned, not assumed: the reviewer's verdicts are compared against your **human** truth (your review-queue corrections and any imported owner decisions) on the same drafts, and the reviewer qualifies only when, defining a "positive" as *a draft that needs a correction*: - it names at least 50 positives, - its precision is at least 0.85, - the lower bound of a 95% confidence interval on that precision is at least 0.75, and - its recall is at least 0.50. Until every bar is cleared, the reviewer's corrections are **audit/rank-only** — they are recorded for inspection, but no lever and no training set reads them. If the bars do not pass, the report says `not_qualified` and nothing changes. ## The switches you control - **Corrections consent.** The reviewer's replacement bodies are stored and can contribute only under your workspace's `corrections_consent`. With consent off, a correction contributes its edit-magnitude only, and no replacement body is kept. Revoking consent stops new derivations and supersedes existing ones. - **Running it, or not.** The reviewer runs only when an operator runs the job for your workspace and call type with a capped key. Not running it is the simplest "off". - **Qualification.** Even with consent on and the job running, nothing teaches until the FLW-21 bars pass. A later dispute of a receipt, or an invalidation of the `(ai_review, v1)` qualification, removes teaching **going forward** (already-recorded events are never rewritten; their derived artifacts are re-derived clean). - **Holdouts stay sacred.** The reviewer never reads a standing-holdout or `lever_holdout` draft, so turning the reviewer on never contaminates the control arm the flywheel measures itself against. ## Running the job ```bash # Review drafts from a completed import, metered through a capped key: pnpm flywheel:ai-review run \ --workspace --call-type \ --import \ --key-file \ --model openai/gpt-5 \ --max-usd 3 # Add --dry-run to size the prompts and the projected spend without any model call. # Compare the reviewer's verdicts to your human truth and, if the bars pass, qualify it: pnpm flywheel:ai-review qualify --workspace --call-type # See the verdict counts and the current qualification state: pnpm flywheel:ai-review status --workspace --call-type ``` The default reviewer model is `openai/gpt-5` — a priced, keyed model that is **not** your writer and **not** either of the screen-kit judges, so the reviewer stays independent of the things it judges. Override it with `--model`. --- # Confirm a candidate in your own app source: https://docs.ultrarouter.io/docs/guides/application-ci (https://docs.ultrarouter.io/docs/guides/application-ci.md) The Flywheel has found a repeated correction in your traffic and screened a candidate that might fix it. Before that candidate is allowed anywhere near your live requests, it has to clear evidence you can inspect: **your own application tests, in your own CI**, then a **randomized live contrast** against a frozen baseline. This M2 quickstart walks that path using only shipped surfaces — the `/v1/flywheel/candidates/:ref/rollout` gate, the signed decision callback, and the **Flywheel** screen — and it tells you plainly where the current build stops short of a button, so you always know which state you are looking at and why. It is bounded on purpose. The runnable example below calls the rollout gate, which **reads** your workspace state and reports readiness. It makes **zero paid model calls**, mutates nothing, and returns in one request. ## What you get — and what you do not yet M2 gives you, without changing how a single live request is served: - a **rollout gate** — one server-side function that reads the four facts your CI cannot see (permissions, consent, budget, qualification) and reports whether a controlled rollout may start, refusing with a **named reason** for each blocker; - a **staged candidate view** on the Flywheel screen — offline screening, your application CI, and live confirmation shown as **three distinct blocks**, never merged into one verdict, plus a single derived progression headline; - a **signed, replay-safe decision record** — a human accept/reject/revert, written as an audited receipt and never as a learning label; - the **adoption lease** and **rollback** lifecycle — an adopted lever expires by default and reverts to the frozen baseline unless it is renewed. What M2 does **not** yet give you: The gate **reports** readiness; it never presses. Starting a controlled rollout, opening live confirmation and adopting are a human action the current build does not yet expose as a one-click button on the candidate card — the rollout response is typed so `pressed` is always `false`, and pressing is owned by a later packet (FW10). Adoption is not a toggle either: it is a **lease** that requires a live confirmation result and a scarce confirmation slot. Where this guide reaches a step the UI does not yet drive, it says what you **will** see and why, rather than showing a fabricated screenshot. The discovery screen that proposes a candidate for you (DS3) is also still under construction; today a screened candidate reaches you through the FW8 screening pipeline and appears on the Flywheel screen. ### The three evidence kinds are never blended This is the load-bearing honesty rule of the whole page, and the code enforces it mechanically (`@ultrarouter/core/flywheel/staged-evidence.ts`): - **Offline screening** is *our* replay on a disjoint split. A screening pass is **not** a live result. - **Application CI** is *your* test/replay harness, in *your* CI. A passed check is **not** a live confirmation — and a direct model-call comparison can never claim application-level coverage. - **Live confirmation** is a randomized contrast against a frozen baseline. It is the **only** evidence that can confirm improvement. The three are derived from disjoint run stages, so an application check can never be presented as a live confirmation, and a screened candidate can never be presented as confirmed. "Passing CI, a merged PR or a model-generated correction is not a business-outcome label" is not a slogan here — it is where the type boundaries are. ## Prerequisites - **The stack running.** Locally, `pnpm dev:e2e` boots the gateway on `http://localhost:8080` and the dashboard on `http://localhost:3000`. These endpoints are part of the learning preview, which is not on the invite-only hosted gateway (`https://api.ultrarouter.io`) yet — run the local stack or point at your own deployment. - **A key and base URL.** Set them once: ```bash export UR_BASE_URL="http://localhost:8080" export UR_API_KEY="ur_sk_live_…" # ur_sk_live_* or ur_sk_test_* ``` - **The `outcomes:write` scope** on that key — the rollout gate and the decision callback are Flywheel writes. New keys are granted it by default; confirm yours with `GET /v1/key` (step 1). - **A screened candidate.** You reach this guide once the Flywheel has screened a `learned_context` candidate for one of your workflows. Until you have your own, every request below still returns a well-formed, honest response — the gate reports blockers for a candidate reference it has never seen rather than erroring. - **The Flywheel screen enabled.** It is behind the `dashboard.flywheel` feature flag (an unknown flag reads off, and `/flywheel` returns 404 until it is on). It is seeded on in dev/e2e. - **Time and cost.** About five to ten minutes. The gate call and the introspection call replay no prompts and make **no model calls** — they cost nothing. ## 1. Confirm your setup `GET /v1/key` describes the key itself — its scopes and caps — so a `200` proves your base URL and key work end to end. Check that `scopes` includes `outcomes:write`. ```bash exec curl -sS --fail "$UR_BASE_URL/v1/key" \ -H "Authorization: Bearer $UR_API_KEY" echo echo "ok: key introspection reachable — confirm 'scopes' lists outcomes:write above" ``` ## 2. Bind the candidate into your application CI Application CI is the RT1 surface (`SDK-54`): the ultrarouter GitHub Action / CLI detects a configured prompt, model, tool/schema, retrieval or Context change, binds it to your workflow IDs and your own application test command, and runs your **baseline** and **candidate** revisions through your **own** test/replay harness, in your **own** CI. The candidate arrives as an immutable reference (`@ultrarouter/core#candidateReference`), so the Flywheel row, the pull request and the harness configuration all point at the same object. Three things about this step are guarantees, not conveniences: - **Your CI produces the headline, out of band.** The gateway records the bound SDK-54 evidence-object hash; the pass/fail lives on your pull request. The headline is one of exactly three: **Ready for controlled rollout**, **Needs evidence**, or **Regression found**. A flaky test, a missing secret, a missing evaluator or an absent replay capability produces a **named** non-success — never success by omission. - **Untrusted code never sees platform secrets.** Fork pull requests receive no secrets and no privileged execution of their code. Repository *write* is a separate grant from reading checks — you can run the whole workflow with only a downloadable patch and grant write later. - **Nothing production mutates during CI.** A gateway-owned module change rides as an immutable candidate reference in the harness config; a code-owned change is a file diff you review before anything is written. This step runs in *your* repository's CI, so there is no ultrarouter endpoint to `curl` here. See the [Flywheel API reference](/docs/api/flywheel) for the exact evidence-object shape the gate reads back in step 3. ## 3. Check the rollout gate (the runnable bounded example) Once your CI has a headline, ask the server whether a controlled rollout may start. The gate is the **one** server-side implementation (`@ultrarouter/core#evaluateRolloutPreconditions`): your CI supplies the facts only it observes (the bound identity + the SDK-54 headline), and the server adds the four facts your CI cannot see. It always returns `200` with `pressed: false` and the **full** list of blockers — you should see everything you must fix, not just the first. ```bash exec curl -sS --fail -X POST \ "$UR_BASE_URL/v1/flywheel/candidates/urcand1_docdemo/rollout" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "schema": "ur.flywheel.rollout.request.v1", "candidate_ref": "urcand1_docdemo", "candidate_state": "screened", "observed_identity": { "repository": "acme/app", "baseSha": "b", "headSha": "h", "lockDigest": "l", "harnessHash": "hh", "configHash": "ch", "datasetSplit": "holdout-a", "workflowId": "wf", "objective": "acc", "evaluatorVersion": "v3", "evidenceEpoch": "e1" }, "application_headline": "ready_for_rollout", "requires_repository_write": false, "change_shape": "gateway_owned" }' echo echo "ok: the gate returned pressed:false with a named blocker list" ``` **Expected state.** A `200` whose body is `{ "unlocked", "pressed": false, "blockers": [...], "evidence_hash", "candidate_ref" }`. `pressed` is the literal `false` — the gate can describe an unlocked rollout but has **no representation** for a started one. `unlocked` is `true` only when `blockers` is empty. On a fresh workspace you will typically see `consent_revoked` and `budget_exceeded` until you grant corrections consent and a confirmation slot is free — each blocker names its own family and reason. The `evidence_hash` is the immutable evidence object the dashboard row and the pull request both link to — recomputable from the bound identity, so "the dashboard and the PR point at the same object" is a recomputation, not a promise. ## 4. What the gate refuses, and why Every refusal is **named and distinct**, because your next action differs for each. There is no generic "blocked", and there is no input whose *absence* is skipped — a fact the server could not read is its **own** `*_unknown` refusal, never an implicit pass. | Blocker `reason` | Family | Your next action | | --- | --- | --- | | `rollout_not_permitted` | permissions | Use a role that may start a rollout. | | `repository_write_not_granted` | permissions | Grant scoped repo write (a *distinct* grant from starting a rollout), or choose a patch-only change shape. | | `consent_revoked` | consent | Re-grant the corrections consent the candidate was derived under. | | `budget_exceeded` | budget | A confirmation slot is a scarce, approved resource — free one or raise the approved cap. | | `candidate_not_qualified` | qualification | Only a `screened` candidate may start a rollout; an `adopted` one is refused loudly (`adopted_is_not_ci`). | | `application_check_not_passed` | qualification | Your CI headline is not **Ready for controlled rollout** (including an absent check). Re-run CI. | | `stale_head` | frozen_baseline | Your application head commit moved after evidence was bound — re-run CI against the new head. | | `app_config_changed` | frozen_baseline | A bound config/lock/harness input changed — re-bind. | | `epoch_changed` | frozen_baseline | The measurement epoch changed; evidence from two epochs is never combined. | | `*_unknown` / `authorization_unavailable` | (its family) | The server could not read that fact — it refuses rather than assume. Retry once the state is readable. | Try it: change `application_headline` to `regression_found` in step 3 and the gate adds `application_check_not_passed`; set `requires_repository_write` to `true` with a `code_owned` change shape and it adds `repository_write_not_granted`. The gate still returns `200`, still `pressed: false` — it is telling you what to fix. ## 5. Record the human decision When a person approves or refuses a candidate, that decision is written as a **signed, replay-safe** callback and stored as an audited receipt with the actor and a **mandatory** reason. It is deliberately **never** a learning label: a human's opinion of a candidate is not an independently verified outcome, so its `learning_use` is hard-wired to `audit`. ```bash # Illustrative — a real call is SIGNED (X-UR-AppCI-Signature / X-UR-AppCI-Timestamp, # the FLW-26 MAC recipe keyed by your API key). Without a valid signature the server # FAILS CLOSED with 403 "missing_signature"; unauthenticated is 401. The signed path # is exercised in the gateway e2e (E2E-FLW-ROLLOUT / TST-FLW-CONFIRMATION). curl -sS -X POST \ "$UR_BASE_URL/v1/flywheel/candidates/urcand1_docdemo/decisions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "schema": "ur.appci.callback.v1", "kind": "decision", "callback_id": "urcb1_…", "workspace_id": "ws_…", "candidate_ref": "urcand1_docdemo", "evidence_hash": "…", "decision": "accept", "actor_id": "usr_…", "reason": "cleared our CI", "surface": "pull_request", "decided_at": "2026-09-15T00:00:00.000Z" }' ``` **Expected state.** With a valid signature: `{ "status": "accepted", "callback_id", "learning_use": "audit" }`. A re-sent identical callback resolves to `"duplicate"` — a **visible** no-op, never a second recording — because the `callback_id` is derived from the callback's own identity. On the dashboard, the same decision is the candidate card's **Approve rollout** / **Refuse** form: it, too, requires a reason and records an audited receipt, and it, too, never presses the rollout itself. ## 6. Live confirmation and adoption — what you will see This is the step the UI does not yet drive with a button, so here is the honest description of the state, not a screenshot. When a rollout is started (FW10), a **confirmation opens against a frozen baseline serving profile** — the exact effective-config version at open time. The candidate is served to a `treated` arm; a fixed `lever_holdout` slice is served the frozen baseline so the contrast is randomized and live. The lift ledger renders **only** as an interval with its sample size, gated by an A/A false-positive check; **superiority** (the lower interval bound above zero) is required before any improvement is claimed, and the preregistered cost constraint must pass. No lever ever reads holdout traffic. **Adoption is a lease, not a switch.** An adopted lever carries an expiry and reverts to the frozen baseline unless someone renews it — "if nobody renews, the lever reverts to the frozen baseline at expiry" is the designed default, so nothing runs unwatched forever. On the Flywheel screen the adopted lever appears under **Adoptions (leases)** with its granted/expiry dates and **Renew lease** / **Roll back to baseline** actions. On the candidate card itself you will see the three evidence blocks from §"The three evidence kinds" side by side — each with its own authority label and verdict — a single progression headline (**Collecting → Pattern found → Ready to test → Passed application checks → In live comparison → Confirmed / No improvement / Inconclusive → Adopted / Rolled back**), the live lift interval when one exists, and the rollout gate rendered as a report (unlocked, or blocked with each named reason). The **Supporting cases** link opens the candidate's own [run trace](/docs/concepts/evals) — the five-section what-happened / what-was-measured / result / inputs-and-outputs / lineage view, with bodies shown as content-hash pointers and gated by consent and retention. ## 7. Rollback and the epoch law Rollback is available two ways, both shipped: - **The lease rollback** — **Roll back to baseline** on an active lease, with a mandatory reason and an audited receipt, returns the call type to the frozen baseline. - **Automatic invalidation** — an application deployment or rollback that changes the bound baseline **invalidates** an open confirmation (`@ultrarouter/core#confirmationInvalidation`) rather than silently crediting an unrelated change as lift. A changed head is `stale_head`, a changed config is `app_config_changed`, a changed epoch is `epoch_changed`, and a changed candidate is `candidate_changed`. Evidence from two measurement epochs is **never** pooled — the code throws rather than combine them. So a merged PR is never, by itself, "measured improvement", and a deploy under a running confirmation never quietly becomes the result. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `401` on the rollout or decision route | The request is unauthenticated. Send `Authorization: Bearer $UR_API_KEY`. | | `403` on `POST …/rollout` | Your key is missing `outcomes:write`. Check `GET /v1/key`; mint or roll a key that includes it. | | `403 missing_signature` on `…/decisions` | The decision callback is signed and therefore api-key only, with a valid `X-UR-AppCI-Signature`. A session cookie has no signing secret. | | Gate returns `consent_revoked` | The workspace has not granted `corrections_consent`. Grant it on the Flywheel screen (the candidate was derived under it). | | Gate returns `budget_exceeded` on every candidate | No confirmation slot is free — a slot is a scarce, approved resource. Free one or raise the approved cap. | | Gate returns `application_check_not_passed` | Your CI headline is not **Ready for controlled rollout**, or no check has run. Re-run application CI and bind fresh evidence. | | `404` opening `/flywheel` | The `dashboard.flywheel` flag is off for your workspace. Enable it, then reload. | | No **Adopt** / **Start rollout** button on the card | Expected — pressing is not yet a UI button (the gate reports; `pressed` is always `false`). Adoption surfaces as a lease once a live confirmation completes. | ## What this proves — and what it does not **Proves.** You can carry a screened candidate into your **own** application CI, ask the server whether a controlled rollout may start, and get back a `pressed: false` report whose every refusal is named and whose missing evidence fails closed — with the offline / application-CI / live evidence kinds kept type-distinct at every step, a human decision recorded as an audit-only receipt, and adoption modeled as a revocable lease with rollback. All of it makes **zero paid model calls**. **Does not prove.** It does **not** prove the candidate is better. Only a live confirmation with a superior interval can do that, and this build does not yet expose the press that opens confirmation or the one-click adopt — so no lift figure, and no business-outcome label, is claimed here. A passing CI check, a merged PR and a model-generated correction are, by construction, none of those things. When FW10 wires the press, the same candidate on the same workspace will move through **In live comparison** to one of **Confirmed / No improvement / Inconclusive**, with the interval, sample and inspectable failures behind it. ## Next steps - Declare what "it worked" means for your outputs first — see [Declare what "it worked" means](/docs/guides/flywheel-objectives). - Plan a straight model swap instead — see [Plan a model migration](/docs/guides/migration-start). - Register the provider keys a live confirmation dispatches through — see [providers & BYOK](/docs/guides/providers-byok). --- # Connect a check (oracles) source: https://docs.ultrarouter.io/docs/guides/connect-a-check (https://docs.ultrarouter.io/docs/guides/connect-a-check.md) Some of your systems cannot produce the right answer, but they can **check** one: a SQL sandbox that runs the generated query, an address validator, a booking dry-run, a reconciliation job that learns the truth a week later, your own CI harness. ultrarouter calls such a check an **oracle**. (A system that can produce the right answer should send a [declared correction](/docs/guides/outcomes) instead.) This page is the API-level contract. The dashboard card and the SDK helpers come later; everything below works today over plain HTTP. ## Kinds, transports and roles | Kind | Transport | What it establishes | | ---------------- | ---------------------- | ------------------------------------------------------------ | | `label_set` | `platform` | the output is one of your labels | | `schema_rules` | `platform` | a JSON schema + rules (`equals`, `within_tolerance`, `ge`, `le`) | | `test_endpoint` | `push` | whatever your endpoint executes | | `reconciliation` | `push` or `pull` | agreement with your system of record | | `customer_ci` | `pull` | your harness run on the candidate | - **platform** — evaluated inside ultrarouter, no network call. - **push** — ultrarouter POSTs each job to your HTTPS endpoint. - **pull** — your worker or CI claims jobs and posts signed verdicts. A check never declares a role; it **earns** one by qualification: - **Detector** — it catches at least 80 % of planted failures and passes at least 90 % of valid rewrites. A detector may flag failures. - **Certifier** — the detector bars **plus zero false passes over at least 73 planted failures** (the Wilson 95 % upper bound on its false-pass rate is at most 5 %). Only a certifier can back a verified fix. A label set can only ever be a detector: a wrong label that is still in the set passes it. ## Register ```bash curl -s "$UR_BASE_URL/v1/flywheel/oracles" \ -H "Cookie: $UR_SESSION" -H "X-UR-Workspace: $UR_WORKSPACE" \ -H 'Content-Type: application/json' \ -d '{ "kind": "test_endpoint", "spec": { "url": "https://checks.example.com/verify", "contract_version": "v1" } }' ``` The response carries the oracle id (`orc_…`), the **version** (the sha256 of the canonical definition — a changed spec is a new version whose qualification starts from nothing) and, for a new push or pull oracle, its **signing secret**: ```json { "object": "oracle_registration", "oracle_id": "orc_…", "version_hash": "…", "transport": "push", "secret": "orsec_…", "secret_last4": "…" } ``` The secret is shown **once**. Store it now; ultrarouter keeps it encrypted and shows only its last 4 characters afterwards. Rotate it with `POST /v1/flywheel/oracles/{id}/rotate-secret` — the old secret stops verifying immediately. Who may register: - a **platform** check — an API key with `config:write`, or a session with the `config.write` permission; - a **push or pull** check — a **session** with the owner/admin `flywheel.oracle` permission. An API key cannot register an endpoint we will call or mint a signing secret. A push endpoint must pass the egress guard at registration and on every call: HTTPS only, and never a private, link-local or cloud-metadata address (a refused endpoint is a `400 egress_blocked`). ## The job and the verdict Every job — a qualification probe, a canary, a connectivity test, a real candidate — has the **same shape**. It is the body we POST to a push endpoint and each entry a pull claim returns: ```json { "object": "oracle_job", "schema": "ur.oracle.v1", "job_id": "orj_…", "oracle_id": "orc_…", "oracle_version": "…", "input": "the task input", "output": "the candidate to judge", "candidate_sha256": "…", "due_at": "…", "issued_at": "…" } ``` Your check answers with one verdict per job: ```json { "job_id": "orj_…", "oracle_version": "…", "candidate_sha256": "…", "verdict": "fail", "reasons": ["wrong_value"] } ``` - `verdict` is `pass` or `fail`. There is no third answer: a timeout, an error or a malformed reply is recorded as an error and **never counts as a pass**. - The verdict must **echo** the `job_id`, the `oracle_version` the job was issued under and the `candidate_sha256`. A mismatch is refused by name (`version_mismatch`, `candidate_mismatch`, `job_mismatch`) and never recorded as an answer. - `reasons` is optional, only on a `fail`, at most 8, from a closed list: `wrong_value`, `missing_field`, `extra_field`, `format_invalid`, `schema_invalid`, `rule_failed`, `execution_failed`, `truncated`, `off_task`, `mismatch`, `other`. An unknown key or free text makes the whole reply `malformed_reply`. ### Signing Every delivery in both directions is signed with the oracle's secret, the same scheme as our webhooks: ``` x-ur-oracle-timestamp: x-ur-oracle-signature: sha256=.")> ``` The timestamp must be within 15 minutes of our clock. A missing, stale or wrong signature is refused with a `403` that names the reason (`missing_signature`, `stale_signature`, `invalid_signature`). ## Push: we call your endpoint For each job we `POST` the job JSON to your URL, signed as above (plus the informational `x-ur-oracle-job` and `x-ur-oracle-version` headers). Your endpoint must: 1. verify our signature; 2. reply `200` with `Content-Type: application/json` and one verdict, **signed back** with the same secret — an unsigned reply is refused, so a hijacked host cannot answer for you. Limits: the connection is pinned to the address we validated, redirects are followed at most twice (each re-validated), the reply is capped at 64 KB, and each call is cut at 10 seconds. ## Pull: your worker claims jobs ```bash BODY='{"max":50,"lease_seconds":900}' TS=$(date +%s) SIG="sha256=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$ORACLE_SECRET" -hex | sed 's/^.* //') curl -s "$UR_BASE_URL/v1/flywheel/oracles/$ORACLE_ID/jobs/claim" \ -H "Authorization: Bearer $UR_API_KEY" -H 'Content-Type: application/json' \ -H "x-ur-oracle-timestamp: $TS" -H "x-ur-oracle-signature: $SIG" -d "$BODY" ``` - The API key needs `outcomes:write` **and** `logs:read` (a claim returns stored task content). Posting verdicts needs `outcomes:write`. - Each job's payload is returned **once** — in the claim response — and then deleted. Keep it until you have posted the verdict. - A claim leases its jobs (30 seconds to 1 hour, default 15 minutes). A verdict after the lease is refused `lease_expired`. Post verdicts to `POST /v1/flywheel/oracles/{id}/verdicts` with `{"verdicts":[…]}`, signed the same way. Per verdict you get `accepted`, `duplicate` (the same verdict again — a visible no-op, forever), `conflict` (a different answer for an answered job — never overwritten) or `refused` with a named reason. When nothing in a post is accepted or duplicate, the whole post is a `409` naming the first refusal. ## Qualify ```bash curl -s "$UR_BASE_URL/v1/flywheel/oracles/$ORACLE_ID/qualify" \ -H "Cookie: $UR_SESSION" -H "X-UR-Workspace: $UR_WORKSPACE" \ -H 'Content-Type: application/json' \ -d '{ "examples": [ { "input": "…", "output": "a known-good answer" }, … ] }' ``` Send at least 10 distinct known-good examples (up to 50 are used; the certifier bar needs at least 37). From each, ultrarouter plants **two** failures — an omitted field, a wrong value, a truncated answer, an answer that ignores the instruction — and adds **one** valid rewrite, then shuffles them. The probes travel as ordinary jobs: your check cannot tell them apart from real work. - A **platform** check is graded at once. - A **push** check receives every probe immediately and is graded when the last one is answered. If any call fails, the result is `incomplete` and nothing is recorded — fix the endpoint and qualify again. - A **pull** check returns `pending`; it is graded when your worker answers the last probe (the verdicts response then lists the graded qualification). A push or pull qualification stores its probes and your examples, so the workspace needs **corrections consent and full retention** (`409 no_consent` otherwise). A false pass is never forgotten: a version that ever passed a planted failure can never become a certifier again. A fixed check is a new version. ## Canaries About one in twenty real jobs your check receives is a planted failure it cannot tell apart from the rest. If your check **passes one**, that version immediately loses its qualification (`check_failed`), and every fix it vouched for stops counting. Canaries are planted from the examples you qualified with; if those are deleted (retention or a consent change), new jobs are refused `no_canary_pool` until you qualify again. Traffic in your holdout is never sent to a check. A job that fixes a request outside the arms the flywheel learns from (the holdout, or a request with no arm) is refused `holdout_excluded`, together with the rest of its batch. ## Test, read, revoke - `POST /v1/flywheel/oracles/{id}/test` — a push check is called at once with a synthetic probe (no customer content) and the response shows the exact job we sent and the verdict or error; a pull check gets a pending test job to claim. Tests are never graded. - `GET /v1/flywheel/oracles/{id}` — the current version, every version, the role it earned, and for push and pull checks the secret's last 4, the canary pool, job counts and the latest qualification's progress. - `POST /v1/flywheel/oracles/{id}/revoke` — terminal. Every version stops certifying, open jobs are cancelled and their content deleted. ## Let a certified check repair failed answers Once your check is a **certifier**, you can let ultrarouter use it to verify and repair the answers of a workflow. You grant this when you confirm the workflow's learning plan (`POST /v1/flywheel/plan/{id}/confirm`, a session action — an API key can never turn it on), with a `repair_policy` block: ```json { "repair_policy": { "oracle_id": "orc_…", "monthly_cap_micro_usd": 5000000, "teacher_model": "openai/gpt-5.2", "sample_rate": 1 } } ``` - `oracle_id` — the check. Its **current version** is pinned; a new version of the check needs a new confirmation. - `monthly_cap_micro_usd` — **required**, in integer micro-USD. There is no default: repairs spend nothing until you set a budget, and `0` means "spend nothing". - `teacher_model` — optional: a stronger model allowed to propose fixes. Without it, that step is skipped. - `sample_rate` — optional, in `(0, 1]` (default `1`): the fixed share of eligible answers that are looked at. The confirm response lists the grant it wrote in `repair_policies` (the call type, the pinned check version, the budget). A malformed grant is a `400 invalid_repair_policy`, an unknown check a `404`, a revoked one a `409`. A **pull** check is a `409 oracle_transport_unsupported`: its verdicts arrive whenever your worker claims its jobs, after the repair pass that would have to wait for them, so a grant naming it could never run (see below). A refused grant confirms nothing — the plan stays open. The grant lives on the workflow's final call type; changing that call type's config does not change it, and a config rollback keeps the grant you confirmed last. What ultrarouter then does on its own, for answers served on the arms the flywheel learns from (never holdout traffic — and never an answer whose record carries no arm: an unknown assignment is skipped before your check is called, because a fix for it could never be used for learning): 1. **Your served answer.** It asks your check about the answer you already served. A pass is recorded as "accepted unchanged". No model is called. 2. **Your application's own retry.** If your application retried the same request in the same trace and got a different answer (for an agent: the same tool, called again with repaired arguments), your check judges that retry. No model is called. 3. **Resamples.** Up to 3 new samples from the model that served the request. 4. **The teacher.** Up to 2 samples from the teacher you confirmed. It stops at the first answer your check passes. Agent workflows (tools, or multi-turn conversations) get step 2 only: a changed earlier action would make the rest of the recorded conversation meaningless. A fix is recorded as a correction backed by a verification receipt; it is used for learning only through that receipt, and only while your check is still a certifier. Your check decides what is right; ultrarouter never uses its own guess, the model's answer or a label of its own as the truth. A pass or a fail is never turned into a label: what is kept is the answer your check passed (your served answer, your application's retry, a resample or the teacher's answer), recorded as an **inferred** correction backed by the receipt — never as one you declared. Spend is held **before** any model call and settled after. A call that succeeds is charged its real cost. A call that fails — an error, a timeout, a dropped connection — may still have been billed by the provider, so it is charged the usage the provider reported for it, or, when the provider reported none, the most that call could have cost (the amount held for it). A failed call counts toward your budget and toward the paid-repair limit below like any other, and that answer is not repaired again under the same grant (never a free second attempt). Calls on your own provider keys count against your budget too. A repair pass verifies up to 25 answers and runs at most 10 paid repairs (steps 3–4); answers your check passes at step 1 or 2 cost nothing and do not count toward that limit. Repairs pause on their own — with a named reason — when the budget is used up, when paid repairs stop working (after 30 of them, if the verified share is too low to be worth it), when your check keeps erroring, or when a canary shows your check passing a planted failure. Push and platform checks can repair today. Pull checks cannot yet: their verdicts arrive after the repair pass, and a paid candidate would have to be kept until your worker answers. Until then, register a push endpoint (or a platform check) for repairs; a pull check still qualifies and still answers canaries and tests. If your system knows the right answer itself — for example the queue a ticket was finally routed to — do not use a check for it: report it as a [declared correction with its final body](/docs/guides/outcomes). That is a declared reference, used for learning while corrections consent is on and deleted when you revoke it. ## What this does not do yet The dashboard card for connecting a check and choosing the budget, a view of the repairs made, and the SDK helpers (`ur.oracles.register`, `ur oracle serve`) come later. --- # Plug in your decision record source: https://docs.ultrarouter.io/docs/guides/connect-decisions (https://docs.ultrarouter.io/docs/guides/connect-decisions.md) If a human already decides whether each thing your agent produced was good — approved it, edited it, rejected it, asked for a fix — then you have an outcome record **today**, before you integrate anything. A **decision connector** reads that record from the system you already run, matches each decided item back to the draft the gateway captured, and records a `correction` outcome for it. You write no code and call no new endpoint. The Flywheel is a **Phase-10 product under construction.** The outcome ledger, the artifact API and this connector are built; the learning and lift surfaces they feed are reported as explicitly incomplete by `GET /v1/flywheel/status`. A connector **imports ground truth** — it proposes no change and ships no treatment. ## The idea A connector is a small adapter behind one generic port: ```ts pull(since) → [{ external_id, final_text, decision, decided_at, ... }] ``` `decision` is one of `approved` · `rejected` · `invalidated` · `fix_requested` — **the human's** decision, kept per delivered item. The connector: 1. **pulls** your decisions over the system's own read API (a credential file, never a key in a flag or a log); 2. **matches** each delivered item's text one-to-one to the gateway's captured draft for the bound call type, by **content** (the pure `diffStats` line diff) with explicit thresholds and a first-class **abstention band** — it never guesses which draft a decision belongs to; 3. **records** a `correction` outcome with `source: imported`: `accepted_unchanged` when the approved item is identical to the draft, `edited` (with diff stats) when it differs, `rejected`, and a fix request as its own typed value. Matching is idempotent (keyed on the connector + the item's external id + the kind) and resumable (pass `--since`). A re-pull is a no-op, never a double count. Bodies are read in memory only; a recorded outcome carries diff **stats**, lengths and bucketed similarity — never your content. ### Pipeline repair vs the human's decision Some systems run their own automated repair on a draft before a human ever sees it. When an `approved` item differs from the draft, the connector records the change honestly: it marks the difference `pipeline_repair` when a repair ran, or `unattributed` otherwise — it never reads a machine's rewrite as if a person had edited the text. The human's decision and the pipeline's repair stay separate on the row. ## socialvp: the first connector socialvp keeps one owner decision per studio delivery (`approved` = delivered, `rejected` = discarded, `invalidated`; a correction the owner asked for is `fix_requested`). The connector reads them over socialvp's platform-admin `GET …/studio-deliveries` (with the delivered post manifest) and matches each short post to the gateway's captured `ct_short_write` draft. ```bash pnpm flywheel:connect socialvp pull \ --workspace \ --api-origin https:// \ --credential-file ./socialvp-connector.json \ [--since 2026-10-01T00:00:00Z] [--dry-run] ``` The credential file is JSON and never printed: ```json { "mode": "admin_assertion", "secret": "", "workspaceId": "", "credentialVersion": 3 } ``` **Least-privileged credential.** socialvp's read API is reached only with its platform-admin assertion (a short-lived signed token) or, on a dev stack, the development header triple — both are **read/write** admin credentials; there is no read-only "deliveries" scope today. Until socialvp adds one, hold the connector credential as you would any admin secret, scope it to the one workspace, and prefer `--dry-run` first. (Issue for socialvp: a read-only `studio-deliveries` scope the connector can present.) `--dry-run` pulls and matches but writes nothing, and prints a body-free summary: how many decisions were pulled, how many matched (by kind and by change attribution), how many abstained (by reason) and how many had no draft. ## The echo measurement (experimental) There is a second, **zero-integration** signal: if a draft **reappears** in a later captured request — for example in a "recent posts" context the writer is given — that is evidence it was published. The echo rule (`nt.echo_published`, v1) detects an earlier draft reappearing in a later body and emits inferred `correction` candidates, abstaining in a borderline band. It is **experimental and never teaches.** Its candidates are `learning_use: audit` only; they never feed training or calibration. Its purpose is **measurement**: ```bash pnpm flywheel:connect echo-measure --workspace --call-type ct_short_write [--since ] ``` scores the echo's precision and recall against the connector's imported truth, and reports the sample size `n` alongside every rate. **What the echo does and does not see.** In socialvp the recent-post list is the last few **deliveries** with no owner-decision filter, and a draft is also fed to its own review/repair stage — so an echo can reflect the **pipeline's own processing**, not the human's keep decision. On recorded socialvp runs the echo's precision was well below one (many reappearances were not kept), with high recall. Read the echo as a coverage probe for the connector's ground truth, never as the decision itself. --- # The context runtime source: https://docs.ultrarouter.io/docs/guides/context-runtime (https://docs.ultrarouter.io/docs/guides/context-runtime.md) The context runtime is a **pure** library that decides, deterministically, what one model call is allowed to see — and records that decision as a hashable **manifest**. It is `@ultrarouter/context`, shipped in the MIT-licensed SDK as `@ultrarouter/sdk/context` (TypeScript) and `ultrarouter.context` (Python). It reads no clock, no environment, no network and no random source: the **same inputs always produce the same manifest hash**. Two promises make it safe to adopt: - **The gateway is a witness, never an author.** When a request carries a manifest (`ur.manifest`), the gateway verifies every declared hash against the bytes it received and stores what it saw; it never adds, drops or reorders a module. The single documented exception is the Flywheel's learned data slot, described at the end of this page. - **The exit is a pure function.** `toMessages(fromMessages(m))` returns a message array byte-identical to `m`. Nothing in the gateway requires a manifest, so you can adopt the runtime in one file and leave in one file. Availability: the runtime and the guarantees on this page ship today in the SDK context subpath. The gateway's `ur.manifest` witness and the dashboard surfaces are part of the Phase 10 build and are documented separately as they ship. This page documents the pure library you can use now — it does not describe a hosted prompt service (there is none). ## Lift → compile → render → exit The runtime has four pure entry points. You can use any of them alone. ### Lift — label an existing prompt `fromMessages` runs the **same segmenter the gateway uses** over a plain OpenAI `messages` array and returns a labelled manifest plus the segmenter's own classification. No model call, no mutation. ```ts import { fromMessages, toMessages } from '@ultrarouter/sdk/context'; const messages = [ { role: 'system', content: 'You are a careful refund assistant.' }, { role: 'user', content: 'Draft a refund reply for order 91237.' }, ]; const { manifest, labels } = fromMessages(messages); // `labels` are the gateway segmenter's kinds (system_instructions, user_question, …) // and eligibility — the SAME code path the hot path runs, never a copy. const restored = toMessages(manifest); // `restored` is byte-identical to `messages`: leaving is a pure function (CTX-50). ``` ```python from ultrarouter.context import from_messages, to_messages messages = [ {"role": "system", "content": "You are a careful refund assistant."}, {"role": "user", "content": "Draft a refund reply for order 91237."}, ] lifted = from_messages(messages) restored = to_messages(lifted["manifest"]) assert restored == messages # the exit is pure — adopt in one file, leave in one file ``` ### Compile — assemble from modules under a contract `compile(envelope, contract, candidates, profile)` runs the normative admission algorithm and returns `{ ok, manifest, refusals }`. Nothing is truncated: a module either fits the budget whole or is shed with a recorded reason. ```ts import { compile, ULTRAROUTER_PROFILE } from '@ultrarouter/sdk/context'; const envelope = { id: 'exec-1', tenant: 'acme', stage: 'assistant', mode: 'live', asOf: '2026-04-01T00:00:00.000Z', cutoffs: {}, budgets: { tokens: 25 }, seed: 'seed-1', pins: [], releases: {}, permissionEpoch: 'epoch-1', }; const contract = { stage: 'assistant', requiredKinds: [], optionalKinds: ['procedure', 'fact', 'history'], forbiddenKinds: [], budgets: { tokens: 25 }, limits: [], pinPolicy: 'none', slots: ['instruction', 'reference', 'exemplar'], }; const module = (id, kind, memoryClass, trustClass) => ({ id, version: '1', kind, memoryClass, trustClass, privacyClass: 'workspace_private', availability: { from: null, to: null }, contentHash: `sha256:${id}`, tokenEstimate: 10, facets: {}, }); const candidates = [ { module: module('runbook', 'procedure', 'procedural', 'approved_derived') }, { module: module('pricing', 'fact', 'semantic', 'authoritative') }, { module: module('thread', 'history', 'episodic', 'approved_derived') }, ]; const { ok, manifest, refusals } = compile(envelope, contract, candidates, ULTRAROUTER_PROFILE); // With a 25-token budget the two most stable modules are admitted (procedural, then // semantic); the episodic `thread` is shed with a BUDGET_EXCEEDED refusal on the manifest. ``` The Python surface is the same call with `snake_case` helpers and camelCase dict keys: ```python from ultrarouter.context import compile, ULTRAROUTER_PROFILE result = compile(envelope, contract, candidates, ULTRAROUTER_PROFILE) manifest = result.manifest # None when result.ok is False refusals = result.refusals # every shed / inadmissible module, with its reason ``` ### Render — turn a manifest into provider messages `render(manifest, bodies, serializer?)` maps each admitted entry's `moduleId` to its body text and emits provider messages. The default serializer concatenates instruction-slot modules into one `system` message and fences reference/exemplar modules behind a safety note so untrusted data can never be read as an instruction. ```ts import { render } from '@ultrarouter/sdk/context'; const bodies = new Map([['runbook', 'Verify the order before offering a refund.']]); const providerMessages = render(manifest, bodies); ``` ## Compatibility profiles `compile` is parameterised by a **profile** so one compiler serves every adopter. A profile carries the ordering rule, the memory-class and refusal vocabularies, the profile-level limits, the render serializer and the manifest encoder (its serialization and hash subject). - **`ULTRAROUTER_PROFILE`** is the default for new adopters. It orders admitted modules by `volatility` — stable material first (procedural), volatile last (episodic) — which is prompt-cache friendly. Ordering is part of `manifestHash`, so switching an existing workload's order rule is a **model-visible change** and goes through the ratchet like any other lever; it is never silent. - **Parent profiles** (the two sibling engines this runtime was extracted from) declare their own `selection` order, memory classes, refusal codes and encoder. Under a parent's profile its fixture corpus compiles **byte-identically** — same manifest bytes, same refusal codes, same rendered messages. That equality is the parity guarantee; a parent's private fields ride on an opaque `ext` that the compiler round-trips but never reads for authority. ## Memory classes Every module declares a `memoryClass`. The core four are a closed vocabulary; a profile can extend them with `defineMemoryClasses(extra)`. The compiler treats each class as an opaque label — it never branches on it — but the default volatility order and shed priority read it, so the class you assign decides what is kept when the budget tightens. | Class | Meaning | Volatility (default order) | | ------------ | ------------------------------------ | -------------------------- | | `procedural` | how to act — rules, runbooks, SOPs | most stable — kept longest | | `semantic` | durable facts and definitions | stable | | `working` | this turn's scratch state | volatile | | `episodic` | conversation and event history | most volatile — shed first | ## Budgets and refusals Admission **sheds whole modules, never truncates**. Mandatory pins are admitted first and are never shed; optional modules are admitted by keep-priority until the token (or count) budget is reached, and each module that does not fit is recorded as a `BUDGET_EXCEEDED` refusal on the manifest rather than being silently dropped. Refusals are data, not exceptions, so one manifest carries both what it admitted and what it refused — the ledger the witness and the Flywheel read. The base vocabulary is closed and includes `STAGE_CONTRACT_MISMATCH`, `REQUIRED_CONTEXT_MISSING`, `FORBIDDEN_KIND`, `AVAILABILITY_REFUSED`, `LIMIT_EXCEEDED`, `BUDGET_EXCEEDED` and `UNTRUSTED_IN_INSTRUCTION_SLOT`. The last one is a fail-closed rule: a module whose `trustClass` is `untrusted` may only land in a reference or exemplar slot; an explicit attempt to place it in an instruction slot is a refusal, and the gateway rejects the same placement as an `invalid_request`. ## The SOP catalog: compact when planning, full when executing A Standard Operating Procedure is authored as prose but **prose never executes**. A reviewed SOP compiles to an immutable `ExecutableSopSpec` — ordered phases with allowed actions, required policies, budgets, stop conditions and artifact contracts — bound to the exact reviewed bytes (`SOP_REVIEW_STALE` if the prose changed after review). Branching is data: a typed transition language (`artifact_present(contract)`, `stop_condition(id)`, `budget_remaining(kind) > n`, and the `all(…)` / `any(…)` combinators) whose graph is checked acyclic at compile time, separately from the dependency graph. The catalog is what makes "dozens of SOPs that load themselves in branches" affordable: each SOP publishes a **descriptor** of ≤ 240 characters (≈ 60 tokens under the repo's chars/4 estimator) — its id, its applicability `when`, its phase names and what it produces, but not its body. The model plans against descriptors only; a full body is loaded lazily, at the gated phase boundary, through a `SopLoadPort` the planning plane never calls. ```ts import { compileSop, catalog, eligible } from '@ultrarouter/sdk/context'; const result = compileSop({ draft, review }); // { ok, spec } | { ok: false, refusals } if (result.ok) { const plan = catalog([result.spec]); // ~60-token descriptors, no bodies loaded const admissible = eligible([result.spec], runtimeState); // ids whose `when` predicate holds } ``` The descriptor budget is an **input contract**, not a hope: an over-budget descriptor is a `DESCRIPTOR_OVER_BUDGET` compile refusal that names which limit blew, never a truncation. A 30-SOP catalog stays under ~2,000 planning tokens — the two or three bodies a run actually reaches are loaded on demand, instead of pasting every procedure into every prompt. Single use is a property of the store, not a claim of a pure function: `authorizePhase` builds an execution-bound receipt `{spec_hash, phase, args_hash, exec_id}` and asks an `AuthorizationReceiptPort` to record it; a reused receipt is refused because the store says `duplicate`. Two fallbacks are themselves manifest entries so they stay visible in the ledger: `mandatoryOnlyManifest` (drop to the phases that must always run when the planner bound is exceeded) and `gapArtifact` (a named "what this run could not observe" value a gate can read). The Python subset ships lift, compile, render, exit, profiles and memory classes today; the Python SOP compiler is a filed follow-up (SDK-53). Use the TypeScript subpath for SOP compilation until it lands. ## Native-delivery adapters When a provider ships its own context features, the runtime can deliver a compiled manifest **through** them instead of pasting everything inline — the runtime decides *what* deserves the window; the provider decides *how* the window is managed. `planNativeDelivery` takes a compiled manifest and a target and decides, entry by entry, which channel carries it. The decision is a **table lookup**: no branch names a provider. v1 supports one provider natively — the Anthropic Messages API — with a frozen mapping: | Manifest entry | Native channel | Provider feature token | | ------------------------------------ | ---------------------------------- | ---------------------------------- | | SOP / tool descriptors | deferred tool (tool search) | `tool_search_tool_regex_20251119` | | episodic modules | server compaction window | `compact-2026-01-12` | | episodic modules | context-editing window | `context-management-2025-06-27` | | procedural modules (app opted in) | memory tool | `memory_20250818` | | turn-scoped operator text | mid-conversation `system` message | model-gated (no beta header) | Two rules keep it honest. **Absence is the fallback:** a (provider, model, feature) triple with no row in the compatibility table — every non-Anthropic provider, and any Anthropic request that does not carry the required beta header or opt-in — takes the ONE documented fallback: the catalog is rendered inline and the manifest records `native_delivery: 'unavailable'`. **Model gating is data:** `claude-haiku-4-5` has no memory tool or mid-conversation-system row, so those entries fall back on that model — a missing row, not a special case. A refusal (`NATIVE_UNAVAILABLE`) is emitted only when the contract *required* native delivery; a contract that merely prefers it degrades silently but recorded. Native and inline delivery carry the **same set of facts** — same module ids, hashes and classifications — only the channel differs. ## Adopt in one file, leave in one file Because the exit is pure and nothing on the hot path needs a manifest, adopting the runtime is reversible by construction: - **Adopt:** import from `@ultrarouter/sdk/context` (or `ultrarouter.context`), lift or compile your prompt, and send `render(...)` output to any OpenAI-compatible endpoint. `ur context lint` (an advisory scanner that never modifies a file) flags oversized inline prompts and untrusted values interpolated into instruction surfaces. - **Leave:** call `toMessages(manifest)` and you are back to a plain `messages` array, byte-for-byte. A stored manifest plus its bodies round-trips as plain JSON. There is no service to turn off. ## The learned slot (Flywheel) The gateway's zero-authority rule has exactly one exception: the Flywheel's **learned data slot**. A consented, holdout-tested learned module — entered as `memoryClass: semantic`, `trustClass: approved_derived`, behind a standing holdout — is the one thing the runtime may inject on your behalf, and only after a live confirmation has proven it. Nothing else the gateway sees is ever added to your context. See [The Flywheel](/docs/concepts/flywheel) for the loop and [Holdout and lift](/docs/concepts/holdout-and-lift) for how an improvement is proven before it can teach. ## Next steps - [The five products](/docs/concepts/products) — where Context sits among Router, Evals, Scoring and Flywheel. - [For agents](/docs/for-agents) — the machine-first surfaces and the product-select blocks. - [Record outcomes](/docs/guides/outcomes) — feed the ledger the learned slot is built on. --- # Capture your first reviewer correction source: https://docs.ultrarouter.io/docs/guides/correction-start (https://docs.ultrarouter.io/docs/guides/correction-start.md) You have an app that produces model output a human reviews before it ships — a support reply, a drafted email, an extraction someone signs off. Every time a reviewer **edits** that output, they are telling you what "good" looks like for your workflow. This M1 quickstart captures that signal: you send a request, record the reviewer's correction, and see the correction **joined to the original output** in the outcome ledger — over the raw REST API and the TypeScript SDK, side by side. It is bounded on purpose. The correction, artifact and import steps below make **zero paid model calls** — they move metadata, not prompts. The only request that costs anything is the one output you choose to have reviewed, and against a local `pnpm dev:e2e` stack even that is free. ## What you get — and what you do not yet M1 gives you a durable, joined record of human corrections: - **A request joined to an artifact** — the model output and the reviewer's verdict hang off the same content-addressed unit of value, so a correction is never an orphaned thumbs-down. - **The correction itself, channel 2** — the human-approved final, recorded with a character-level diff (edit ratio, changed spans, lengths, a content hash). Without `corrections_consent` the text never leaves your process; only those stats and hashes are stored. - **Signal health and costs** — the reviewer integration's named states and the four separated Flywheel usage quantities, so you can see what is being captured and what it costs before anything is billed. - **History import** — bring eligible past corrections in one call, with a repairable receipt for any row that does not parse. A captured correction is **joined and stored — it is not yet a learned consequence.** Turning corrections into learned context, testing them through your real application, and running a controlled live comparison is **M2** — the [application-CI and live-confirmation quickstart](/docs/guides/application-ci), published as it ships. At M1 the ledger's `learning` block reports `unavailable` / `collecting`, and this page never implies a closed loop. Capturing the signal honestly is the whole job here. ## Prerequisites - **The stack running.** Locally, `pnpm dev:e2e` boots the gateway on `http://localhost:8080`. These endpoints are part of the learning preview, which is not on the invite-only hosted gateway (`https://api.ultrarouter.io`) yet — run the local stack or point at your own deployment. - **A key and base URL.** Set them once: ```bash export UR_BASE_URL="http://localhost:8080" export UR_API_KEY="ur_sk_live_…" # ur_sk_live_* or ur_sk_test_* ``` - **The `outcomes:write` scope** on that key — every artifact, correction and import step needs it. New keys are granted it by default; confirm yours in step 1. Use a `ur_sk_test_*` key to rehearse: **test-mode data is captured but never enters learning, invoicing or your allowance** (doc 17 §4). - **Consent, only where a body is stored.** Recording a correction as diff stats needs **no** consent. Storing the corrected *text* (or importing intact request/response traces) is gated on `corrections_consent` — see [Consent and cost controls](#consent-and-cost-controls). - **Time and cost.** About five to ten minutes. No per-correction fee, and the bounded fixtures below cost **nothing** — a metadata-only correction and a usage-only import make no model calls. ## 1. Confirm your setup `GET /v1/key` describes the key itself — scopes, caps and usage this period — so a `200` proves your base URL and key work end to end. Check that the returned `scopes` include `outcomes:write` before you go on. ```bash exec curl -sS --fail "$UR_BASE_URL/v1/key" \ -H "Authorization: Bearer $UR_API_KEY" > /dev/null echo "ok: key introspection reachable — confirm 'scopes' lists outcomes:write" ``` ## 2. Send a request, then join it to an artifact First send the request whose output a reviewer will correct, and capture its `ur.request_id`. Then create the **artifact** — the durable unit of value — and declare that this request assembled it. The correction in step 3 attaches to this artifact, so the request and the reviewer's verdict end up on the same row. ```bash # 1. Send the request. The `ur` block on the response carries the request id. curl -sS "$UR_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5-mini", "messages": [{ "role": "user", "content": "Draft a reply to support ticket 4817." }] }' # → response.ur.request_id, e.g. "req_01J…"; response.choices[0].message.content is the draft. # 2. Join that request to an artifact keyed to your workflow. curl -sS "$UR_BASE_URL/v1/artifacts" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "support-reply:ticket-4817", "archetype": "draft", "request_ids": ["req_01J…"] }' # → { "artifact": { "id": "art_01J…", "current_version": 1, "request_ids": ["req_01J…"], … } } ``` {/* not exec: illustrative SDK usage — imports @ultrarouter/sdk and makes a paid model call; the DOC-31 runner executes plain Node with no workspace SDK. The bounded flow is proven by the exec’d curl + TypeScript blocks in §4. */} ```ts import UltraRouter, { diffStats } from "@ultrarouter/sdk"; const ur = new UltraRouter({ apiKey: process.env.UR_API_KEY, baseURL: process.env.UR_BASE_URL, // http://localhost:8080 locally }); // 1. Send the request whose output a human will review. const draft = await ur.chat.completions.create({ model: "openai/gpt-5-mini", messages: [{ role: "user", content: "Draft a reply to support ticket 4817." }], }); const draftText = draft.choices[0]?.message?.content ?? ""; // 2. Join that request to an artifact — the unit of value the correction attaches to. const artifact = await ur.ur.artifacts.create({ key: "support-reply:ticket-4817", archetype: "draft", request_ids: [draft.ur.request_id], }); ``` **Expected state.** A `201` with an `artifact` object: an `id`, `current_version` `1` (the initial draft version — no correction recorded yet), and your `request_ids` echoed back. An artifact belongs to at most one request-group — re-declaring a request that already belongs elsewhere is a `409`, not a silent move. ## 3. Record the reviewer's correction The reviewer edits the draft. Record the approved output as the artifact's **final** (channel 2 — the human's verdict). Two paths, and the page is explicit about which one stores what: - **Metadata-only (no consent required).** Send client-computed `diff_stats` + `final_hash`. The corrected text never leaves your process — only the edit ratio, changed-span count, lengths and content hash are stored. This is the default. - **Body stored (needs `corrections_consent`).** Send `draft` + `final`; the gateway stores the corrected text in the consent-gated blob store and computes the diff server-side. You need this only when you later want to learn from the exact text (M2). ```bash # Metadata-only correction: never sends the text, only the diff stats + hash. curl -sS "$UR_BASE_URL/v1/artifacts/art_01J…/final" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "final_hash": "3b1f…e9", "diff_stats": { "edit_ratio": 0.08, "changed_spans": 1, "lengths": { "draft": 220, "final": 214 } } }' # → { "object": "artifact_version", "version": 2, "stored": "metadata_only", "client_computed": true, … } ``` {/* not exec: illustrative SDK usage — imports @ultrarouter/sdk (unresolvable in the plain-Node snippet runner); the /final path is proven by the exec’d blocks in §4. */} ```ts // The reviewer's edit. `diffStats` is pure and local — it computes the edit ratio, // changed spans, lengths and a SHA-256 of the final without the text leaving the process. const finalText = draftText.replace("Regards,", "Warm regards,"); const stats = diffStats(draftText, finalText); // Metadata-only path (no consent): send the stats + hash, never the body. const finalVersion = await ur.ur.artifacts.final(artifact.id, { final_hash: stats.final_hash, diff_stats: stats, }); // finalVersion.stored === "metadata_only"; finalVersion.client_computed === true. // Consent-on path instead: pass the bodies and let the gateway store + diff them. // await ur.ur.artifacts.final(artifact.id, { draft: draftText, final: finalText }); ``` **Expected state.** A `200` with `object: "artifact_version"`, an incremented `version`, and `stored` reading `metadata_only` (no consent) or `blob` (consent on, body sent). The provenance — `source`, `producer`, `learning_use` — is assigned by the server from your credential; a caller-supplied value is a `400`, never a silent strip. ## 4. See the correction joined to the original output Read the artifact back. The request you sent in step 2 and the correction from step 3 are on the **same** row: `request_ids` still lists the original request, and `current_version` has moved to the corrected version. ```bash curl -sS "$UR_BASE_URL/v1/artifacts/art_01J…" \ -H "Authorization: Bearer $UR_API_KEY" # → { "artifact": { "id": "art_01J…", "current_version": 2, # "request_ids": ["req_01J…"], "archetype": "draft", … } } ``` {/* not exec: illustrative SDK usage — imports @ultrarouter/sdk (unresolvable in the plain-Node snippet runner); the joined read is proven by the exec’d blocks below. */} ```ts const joined = await ur.ur.artifacts.get(artifact.id); console.log(joined.request_ids); // includes draft.ur.request_id — the original request console.log(joined.current_version); // the corrected version from step 3 ``` Run the whole loop against your dev stack in one shot — this creates an artifact, records a correction on it, and reads back the joined version: ```bash exec # A bounded, self-contained correction: create an artifact, record a reviewer's # correction as its final version, and read the JOINED result. Zero model calls. key="correction-quickstart:$(date +%s)-$RANDOM" id=$(curl -sS --fail "$UR_BASE_URL/v1/artifacts" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d "{\"key\":\"$key\",\"archetype\":\"draft\"}" \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["artifact"]["id"])') ver=$(curl -sS --fail "$UR_BASE_URL/v1/artifacts/$id/final" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{"final_hash":"3b1fe9","diff_stats":{"edit_ratio":0.08,"changed_spans":1,"lengths":{"draft":220,"final":214}}}' \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["version"])') curl -sS --fail "$UR_BASE_URL/v1/artifacts/$id" \ -H "Authorization: Bearer $UR_API_KEY" \ | python3 -c "import sys,json; a=json.load(sys.stdin)['artifact']; assert a['current_version']==$ver, a; print('ok: correction joined at version', a['current_version'])" ``` The same loop over `fetch` — the TypeScript equivalent, executed against your stack in CI (DOC-31), so the create → final → joined-read path never rots silently: ```ts exec // The same bounded correction loop as above, over the raw HTTP the SDK wraps: create an // artifact, record the reviewer's correction as its final version, and read back the // JOINED result. Only fetch + process, zero model calls — it runs hermetically. const base = process.env.UR_BASE_URL; const auth = { Authorization: `Bearer ${process.env.UR_API_KEY}`, "Content-Type": "application/json", }; const created = await fetch(`${base}/v1/artifacts`, { method: "POST", headers: auth, body: JSON.stringify({ key: `correction-quickstart-ts:${Date.now()}-${Math.random()}`, archetype: "draft", }), }); if (!created.ok) { console.error("create HTTP", created.status); process.exit(1); } const artifactId = (await created.json()).artifact.id; const recorded = await fetch(`${base}/v1/artifacts/${artifactId}/final`, { method: "POST", headers: auth, body: JSON.stringify({ final_hash: "3b1fe9", diff_stats: { edit_ratio: 0.08, changed_spans: 1, lengths: { draft: 220, final: 214 } }, }), }); if (!recorded.ok) { console.error("final HTTP", recorded.status); process.exit(1); } const version = (await recorded.json()).version; const read = await fetch(`${base}/v1/artifacts/${artifactId}`, { headers: auth }); const joined = (await read.json()).artifact; if (joined.current_version !== version) { console.error("version mismatch", joined); process.exit(1); } console.log(`ok: correction joined at version ${joined.current_version}`); ``` ### Automating it: the reviewer adapter Wiring a review queue by hand is fine for one correction; for a stream of them, the **reviewer adapter** is the one supported integration. `GET /v1/flywheel/reviewer/setup` returns the decision → `(channel, kind)` mapping and the HMAC signature recipe, and `POST /v1/flywheel/reviewer/test` runs a guided, joined test end to end without touching your data. In TypeScript the client signs and durably delivers each decision for you: {/* not exec: illustrative SDK usage — imports @ultrarouter/sdk (unresolvable in the plain-Node snippet runner) and depends on a pre-existing artifact; the reviewer routes are exercised in the integration suite. */} ```ts await ur.ur.reviewer.setup(); // mapping preview + signature recipe (no secret echoed) await ur.ur.reviewer.test("happy"); // a guided, joined test pair await ur.ur.reviewer.record({ // one reviewer decision, durably queued + signed event_id: "rev-4817", artifact_key: "support-reply:ticket-4817", artifact_version: 1, decision: "approved_with_edits", effective_at: new Date().toISOString(), final: { hash: "3b1fe9" }, }); const health = await ur.ur.reviewer.health(); // the six named integration states ``` Only `approved_with_edits` / `approved` accept a final; `rejected` and `regenerated` are recorded as decisions without one. Every reviewer decision maps to the `correction` channel — the adapter reuses the same ledger service as the API above, so there is no second ingestion path. ### Tool-calling agents: correct the next tool call If your workflow is an **agent** — the request declares `tools` and the model answers with a tool call rather than text — the reviewer's correction is the **right next tool call**, not a rewritten paragraph. Send that corrected tool call as the artifact's `final` under `corrections_consent`, either as a bare `{ "name": …, "arguments": … }` object or as a full OpenAI completion body carrying one `tool_calls` entry: ```bash # The agent turn declared `tools`; the served answer was a tool call. The reviewer's # corrected NEXT TOOL CALL becomes the artifact's final (needs corrections_consent to store). curl -sS "$UR_BASE_URL/v1/artifacts/art_01J…/final" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{ "draft": "{\"name\":\"get_order_status\",\"arguments\":{\"order_id\":\"WRONG\"}}", "final": "{\"name\":\"get_order_status\",\"arguments\":{\"order_id\":\"ORD-4817\",\"include_history\":true}}" }' # → { "object": "artifact_version", "version": 2, "stored": "blob", … } ``` An `accepted_unchanged` correction (the served tool call was already right) records the served call as the reference; an `edited` correction records your corrected call. Once enough distinct corrected tool calls are captured for a call type, the product compiles a **tool-call evaluator** for it: a deterministic scorer that grades whether a candidate calls the right tool with the right arguments (the tool name must match; arguments are compared on the fields your correction sets), with the declared tool contract as a hard gate. This is what lets an agent workflow qualify an evaluator at all — a tool-calling turn has no text label for the text-shaped path to grade. Corrections that are neither a clean single tool call nor clean text (a hybrid answer, or more than one tool call in a turn) are **excluded and counted**, never guessed at. Qualifying a tool-call evaluator is still **capture**, not a closed loop. It makes the call type gradeable; turning that into a tested, live-confirmed change is **M2**. No quality or cost-per-task claim is made here. ## 5. Bring history, and repair a failed import row You do not have to wait for new traffic — import eligible history in one call. A **usage-only** import (`usage_summary`) gives per-model exposure and spend and needs no consent; **intact traces** (with request/response bodies and prior review outcomes) are gated on `corrections_consent` and redacted on ingest. Ingest is idempotent per record content hash, and a malformed row is **rejected with a named reason** — never guessed. ```bash exec # Import a two-row usage history where ONE row is malformed (no `model`). The good row is # accepted; the bad one is not — and the receipt names why. Nothing is guessed. imp=$(curl -sS --fail "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{"format":"usage_summary","records":[ {"model":"openai/gpt-4o","requests":12,"input_tokens":4800,"output_tokens":960,"cost_usd":0.21}, {"requests":3,"input_tokens":900,"output_tokens":120,"cost_usd":0.04}]}') id=$(printf '%s' "$imp" | python3 -c 'import sys,json; d=json.load(sys.stdin)["import"]; assert d["accepted_count"]==1 and d["record_count"]==2, d; print(d["id"])') echo "ok: import $id accepted 1/2 rows; the malformed row is named in report.reasons" # REPAIR: fix the row and re-send with the SAME import_id. Idempotent per record content # hash, so the good row dedupes and only the repaired row lands — never double-counted. curl -sS --fail "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d "{\"format\":\"usage_summary\",\"import_id\":\"$id\",\"records\":[ {\"model\":\"openai/gpt-4o\",\"requests\":12,\"input_tokens\":4800,\"output_tokens\":960,\"cost_usd\":0.21}, {\"model\":\"openai/gpt-4o-mini\",\"requests\":3,\"input_tokens\":900,\"output_tokens\":120,\"cost_usd\":0.04}]}" \ | python3 -c 'import sys,json; d=json.load(sys.stdin)["import"]; assert d["accepted_count"]>=1 and d["duplicate_count"]>=1, d; print("ok: repaired row accepted; the already-good row deduped")' ``` **Expected state.** The first call returns `accepted_count: 1`, the other row counted under `rejected_count` / `unsupported_count`, and `report.reasons` maps the reason (a usage row requires a `model`) to a count. The repair call, resumed with the same `import_id`, returns the repaired row as `accepted` and the unchanged row as `duplicate`. The dashboard **Flywheel** screen shows the same receipt: each rejected row's state is named, and a reviewer event that could not be joined (a mismatched draft version) is a first-class **dead letter** you re-drive with `POST /v1/flywheel/reviewer/repair` — a repair re-runs the stored event, it never rewrites a recorded consequence. ## Consent and cost controls **Consent is a gate on stored bodies, not on capture.** Recording corrections as diff stats, and usage-only imports, work with no consent at all. `corrections_consent` (owner-only, audited) is required only to store the corrected *text* or to import intact traces; revoking it stops new derivations and marks existing exports — it never rewrites what is already recorded. A separate `training_export_consent` governs the customer-owned export; neither defaults on. **Capture is not billed per event.** Artifact and outcome counts are operational counters, not charges — there is **no per-correction fee**. What is billable is bounded analysis and approved evaluation compute (an M2 concern), reserved and settled through the existing budget engine, with failed attempts counted in actual cost and retries never double-charged. Test-mode keys are excluded from all of it. `GET /v1/flywheel/status` shows the four separated quantities — tracked artifacts, accepted outcomes, analysis units, evaluation compute — plus storage usage against your quota (which pauses overage rather than surprising you) and the honest `learning` block: ```bash curl -sS "$UR_BASE_URL/v1/flywheel/status" \ -H "Authorization: Bearer $UR_API_KEY" # → { …, "learning": { "flywheel_class": "unavailable", "flywheel_class_reason": "collecting", # "active_levers": [], "calibration_available": false } } ``` That `unavailable` / `collecting` is the honest M1 state: signal is being captured; no lift is claimed, because none has been established. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `403` creating an artifact or recording a final | The key is missing the `outcomes:write` scope. Check `GET /v1/key`; mint or roll a key that includes it. | | `400` "corrections_consent is off — send client-computed diff_stats" | You sent `final`/`draft` (a body) without consent. Send `diff_stats` + `final_hash` instead, or grant consent in Settings. | | `409` creating an artifact | The `request_ids` you declared already belong to another artifact — a request joins at most one artifact. Use a fresh key or drop the shared request. | | `400` on an outcome/final with `learning_use`/`source`/`producer` | Provenance is server-assigned; a caller-supplied value is refused, never stripped. Remove the field. | | Import row shows under `rejected_count` | Expected for a malformed row — read `report.reasons` for the named cause, fix the row, and re-send with the same `import_id`. | | `403` importing intact traces | Intact-trace **commit** needs `corrections_consent`. Grant it, or send `"mode": "preview"` to inspect the capability report without storing a body. | | Reviewer webhook `403` `missing_signature` | The `POST /v1/flywheel/reviewer/events` webhook is signed and api-key-only. Use the SDK (`ur.ur.reviewer.*`), which signs for you, or follow the recipe from `GET /v1/flywheel/reviewer/setup`. | ## What this proves — and what it does not **Proves.** You can go from a served request to a durable, **joined** record of a human reviewer's correction — over REST or the SDK — with the corrected text kept in your process unless you consent to store it, past history imported and any failed row repairable, and the capture counted separately from anything billable. No model call is needed for the correction itself, and there is no per-event fee. **Does not prove.** It does **not** prove any correction has improved your app. A captured correction is a joined observation, not a learned consequence: at M1 nothing is trained, no lever is active, and the ledger's `learning` block says so. Turning these corrections into learned context, testing them through your real application, and confirming a change with a controlled live comparison is **M2** — the [application-CI and live-confirmation quickstart](/docs/guides/application-ci) — and until that evidence exists, no quality or cost-per-task claim is made here. ## Next steps - Register [what "it worked" means](/docs/guides/flywheel-objectives) for a type of artifact — the one thing the product refuses to infer. - Grant `corrections_consent` and set retention in the dashboard **Settings** if you want to store the corrected text for later learning. - The [application-CI and live-confirmation quickstart](/docs/guides/application-ci) (M2) covers turning these corrections into a controlled comparison, and is published as it ships. --- # Find a workflow worth learning source: https://docs.ultrarouter.io/docs/guides/discovery-start (https://docs.ultrarouter.io/docs/guides/discovery-start.md) Flywheel is workflow-first. You do not fill in a five-toggle form to get started — you point your existing app at ultrarouter, let it read the calls you already make, and it surfaces **one workflow worth learning** with a complete, inspectable plan. Your only decision is to confirm what "it worked" means for that workflow. Until you do, nothing is learned. This quickstart walks that path against a local `pnpm dev:e2e` stack: connect, see the structural a-ha, read the [semantic a-ha](/docs/concepts/discovery) as a proposed learning plan over the API, and confirm it. It makes **zero paid model calls** — discovery reads your existing traffic and moves metadata, it does not send new prompts. ## Minute zero: you do not have to wait for traffic You do not have to send a single request to see a proposal. From your **public website** alone, ultrarouter infers the workflows you run and a provisional definition of good, and shows them at minute 0: *we think you run these workflows and good means X — **confirm all**, edit, or skip.* This is the plug-and-play path. - **Minute 0 (inferred).** From the site brief, a **bounded list of inferred workflows** (up to 8), each with a provisional quality bar, cost budget and outcome channel — clearly labelled **inferred, not measured**. They sit beside any workflow you declare through the SDK/manifest. Signup analysis is **multi-page**: it fetches several same-origin pages of your own site (through the one SSRF egress guard — cross-origin links are dropped before any fetch), runs several small-model **categorise** passes per page against a closed workflow taxonomy — **each pass presents the taxonomy in a different order** so the passes cancel the model's list-position bias — and merges the recorded runs into the list: a workflow is kept only when a **majority of a page's runs agree** on it (so one stray or injection-steered run can never add a false positive), and its support is the number of pages that agree. Deterministic, with the pages that evidence each workflow recorded. It runs once, under a **stated one-time signup-analysis budget** — separate from your run-time spend cap and from the analyzer's monthly discovery cap; a changed site re-runs it, an unchanged site recomputes and spends nothing. The `GET /v1/workspaces/:id/site-brief` status prints that budget (`analysis.budget`) and the per-page status (`analysis.pages` — analysed / skipped-cap / fetch-refused / budget-exhausted / **flagged-injection** / screen-failed); it never returns the page text. Your site is untrusted data: each categorise answer is schema-checked to the closed taxonomy and every inferred workflow is a proposal you confirm, so a hostile page can steer nothing but a suggestion. - **Minute 15 (traffic-backed).** After your 20th request, the proposal is revised from your own traffic — now labelled *from your traffic*. - **Day 1–3 (semantic).** The full [semantic a-ha](/docs/concepts/discovery) — the workflow's measurement bundle, alternatives and the one-sentence preview. Each proposal carries its **evidence basis** (an inferred proposal is never presented as measured), and **`Confirm all (N)`** is the one-tap primary action — it states the exact count it will confirm. Clicking it **is** your confirmation — nothing auto-confirms. It confirms every proposal through the **same** per-plan confirm path used below, so the label field and the decision contract apply to each; the inferred site-brief proposal is confirmable too (its objective is reconstructed from the inferred intent, and it compiles to `audit_only` — a proposed objective and an unobserved channel never teach). Per proposal you can also **Confirm** (just that card), **Edit**, or **Skip**. **Skip** removes a workflow from `Confirm all` — you never confirm something you declined; if we later revise that workflow from new evidence it returns, marked *revised since you skipped*. **Edit** stages what "better" means for that card, and `Confirm all` (or the per-card **Confirm**) then confirms your edited choice, not the default. The server re-reads the eligible set when you press `Confirm all`, so a stale page can never confirm a card you skipped. How many workflows you see depends on your evidence: the multi-page site analysis seeds a bounded list (up to 8) from your site alone, and declared and traffic-derived sources add to it. ### What the minute-0 screen shows The screen is **one deduplicated list** — the multi-page analysis's specific workflows replace the single generic "your product workflow" seed when both exist, so you never see the same workflow twice. Each card shows **what we inferred and from what**, so an inference always reads as an inference (never as a measurement): - **The workflow label** — the category we detected (for a site-inferred workflow) or the call type (for a declared one), with its provisional *good means X* (the quality bar, the outcome channel, and the window). - **An evidence basis chip** — *From your website* (inferred) or *From your traffic* (declared). - **Why we inferred this** — for a site-inferred workflow, the pages of **your own site** that agree on it and how many independent reads agreed ("Pages that agree: 2 · Agreeing reads: 5"), listing each page by path. We never show the page text — only the path, a count and a hash. Below the cards, a calm **"pages we skipped"** section names, in plain words, every page the analysis did **not** use to infer your workflows — a page that *looked like instructions aimed at us* (`flagged_injection`), one we *could not safely screen* (`screen_failed`), one the *one-time analysis budget ran out* before, or one with *not enough agreement to use it* (`insufficient_runs`) — each with its closed reason codes. Skipping is **non-destructive**: nothing is removed, and a skipped page steers nothing. The section also states the one-time analysis budget the whole pass ran under. ### How we treat your site as untrusted Every page we fetch is **untrusted data**, never an instruction. The primary controls are structural: your page rides inside a neutralised data envelope, each categorise answer is constrained to a **closed workflow taxonomy** (a slug we do not offer is dropped), and a workflow enters your list only when a **majority of a page's independent runs agree** — so a single steered run changes nothing. On top of that, each fetched page is run through a dedicated **injection screen** *before* it is categorised. The screen is two layers: - A **deterministic pre-screen** that runs on the raw HTML and the extracted text and catches the mechanically detectable attempts — instructions hidden in HTML comments or `display:none` / `aria-hidden` blocks, base64-encoded or zero-width / homoglyph-obfuscated payloads, and self-labelling claims like "already verified" or "the analyzer must…". - A **model screen** whose only output is a verdict (`clean` or `suspected_injection`). A page either layer flags is marked **`flagged_injection`**, **excluded from categorisation**, and **named to you** in `analysis.pages` with a set of reason codes (never the page text). A page we could not screen (a failed screen call) fails **closed** — it is not categorised either, and is named `screen_failed`. A payload **split across two pages** never assembles: each page is screened and categorised on its own, with no cross-page context. And the screen calls draw on the **same one-time signup-analysis budget** as categorisation, so a page that says "analyse a thousand pages" or "spend your budget" moves nothing — the crawl cap, the budget, the closed taxonomy and majority agreement already bound it. ## Prerequisites - **The stack running.** Locally, `pnpm dev:e2e` boots the gateway on `http://localhost:8080` and the dashboard on its configured port. These endpoints are part of the learning preview, which is not on the invite-only hosted gateway (`https://api.ultrarouter.io`) yet — run the local stack or point at your own deployment. - **A key and base URL.** Set them once: ```bash export UR_BASE_URL="http://localhost:8080" export UR_API_KEY="ur_sk_live_…" # ur_sk_live_* or ur_sk_test_* ``` - **The `config:read` scope** on that key to read the plan projection over the API. Confirming a workflow is a signed-in action — it needs a **session** and the `flywheel.write` role, never an API key — but it is not UI-only: the dashboard **and** `POST /v1/flywheel/plan/:id/confirm` share the one confirm assembly, so an SDK/agent with a signed-in session can confirm over HTTP too (a proposed objective never teaches, so the mint stays a human step). - **Some real traffic.** Discovery needs your own calls to read. Point an app you already run at the base URL above, or replay a slice of history — a handful of call types across a few dozen requests is enough to reach the structural a-ha. ## 1. Connect your traffic There is nothing to configure. Change the base URL and key in the app you already have, and ultrarouter begins reading your calls in **audit mode** — it serves every request unchanged and learns the shape of your traffic as a side effect. A `200` from key introspection proves your base URL and key reach the gateway: ```bash exec curl -sS --fail "$UR_BASE_URL/v1/key" \ -H "Authorization: Bearer $UR_API_KEY" > /dev/null echo "ok: gateway reachable — your traffic is now being read in audit mode" ``` ## 2. Read the structural a-ha Open the **Learn** onboarding in the dashboard. Shortly after your traffic starts flowing it shows a picture of your account assembled from your requests alone: - your **call types ranked** by the share of spend each accounts for, each with a request count and an archetype hypothesis (a prior, shown as such); - **contract-derived evaluator drafts** — editable starting points for how each call type could be checked; - **mechanical and next-turn defect counts, each with its `n`**; - the **outcome channels detected in your traffic, each with its rung** on the `possible → configured → observed → qualified` ladder. This step is diagnostic. It tells you what you are running and what could be measured — it never claims a workflow or an improvement. ## 3. Read the semantic a-ha as a proposed plan Once enough signal accrues past the noise gate, ultrarouter proposes **one workflow worth learning** and assembles its plan. You can read that plan projection for a workflow over the API. It returns metadata only — ids, hashes, counts and the derived program — **never a prompt or a body**: ```bash # Read the proposed learning plan for a workflow (needs a key with `config:read`). curl -sS "$UR_BASE_URL/v1/flywheel/plan?workflow=$WORKFLOW_ID" \ -H "Authorization: Bearer $UR_API_KEY" # While still collecting → { "object": "learning_plan_envelope", "workflow_id": "...", "plan": null } # Once a proposal exists → plan.review_state: "proposed", with call_type_bindings and # per-field provenance. A proposed plan carries NO artifact_type_version_id and NO # spec_hash — because a proposal never teaches (see below). ``` **Expected state.** While the workflow is still `collecting`, `plan` is `null` — an honest state, never a faked plan. Once a proposal exists, `plan.review_state` is `"proposed"` and the plan carries its `call_type_bindings` and per-field provenance, but its `artifact_type_version_id` and `spec_hash` are **empty**: a proposal has no compiled program, because it teaches nothing until you confirm it. The dashboard renders the same proposal richly: the recommended workflow, at most two alternative objectives, "why we suggested this", an **estimate** of resolved outcomes per week with its `n` and interval, the channel and any missing source, provenance chips (`From your traffic` / `Drafted` / `Yours`), and the one-sentence preview *Store X when Y happens; learn Z; apply it to calls W*. ## 4. Confirm one workflow Confirming is the whole job — and it is deliberately a signed-in dashboard action, because it is the boundary where a draft becomes something the product may act on. Review the proposal, edit any field (or **describe the objective in your own words**, within the shown per-day and length limits), and Confirm. Each verb — Confirm, Edit, Merge, Split, Decline (with a coded reason) — is recorded as product telemetry and audited. Under the hood the dashboard records the verb through `POST /v1/flywheel/plan/:id/review` and, on Confirm, calls the audited confirm service that writes the confirmed objective version in one transaction — the SAME service `POST /v1/flywheel/plan/:id/confirm` exposes, so the confirm is reachable over HTTP with a signed-in session (never an API key). **What confirming does — and the law it obeys.** Confirming mints a *confirmed plan*, the only input the trigger compiler accepts. This is a type boundary, not a runtime toggle: a drafted (LLM-suggested) value is not assignable into a confirmed plan, and a proposed objective reaching the compiler is a compile error. The confirm service reads the values **you** authorised, never the draft's proposed values — so editing a suggestion changes what you review but can never change what a confirmed plan teaches. This is why **a proposed objective never teaches**: there is no path from a proposal to a lesson that skips your confirmation. **Naming the label field (`label_field`).** For a workflow whose "right answer" is a field in a structured response, you can confirm **which field is the label** the reference-agreement objective grades. Discovery **proposes** it for you: the plan review (`GET /v1/flywheel/plan`, and the discovery card in the dashboard) carries a `label_field_proposal` for each bound call type — its `path`, its `basis` (`corrections` · `convention` · `none`) and a `changed`/`total` count per candidate field. The proposal is **structural, never an LLM guess** — among your contract's scalar leaf fields it picks the one your declared corrections change most often (edited finals vs what was served), tie-broken to a field literally named `label`. It is **consent-gated**: the change signal is derived only under corrections consent (`basis: corrections`); without consent no correction body is read and it degrades to the `convention`/`none` basis. You then **confirm** it: pass `label_field` (a dot-path such as `intent` or `outcome.label`) on the confirm request, accept the proposed default in the card, or edit it. Naming it is what makes evaluation plug-and-play when your label field is called `intent`, `category` or `decision` rather than `label`: an incidental field the objective does not name (a `notes` a model rewrites each time) then never counts against a right answer. It is a confirmed plan field like any other — only a value **you** confirm enters the plan, never a bare proposal. The proposal is recomputed only while the plan is **unconfirmed** (the review moment it serves); once you confirm, `GET /v1/flywheel/plan` returns the confirmed field (`label_field` + its origin, `label_field_proposal: null`) and reads no correction body — so a dashboard poll of a confirmed plan is free of audit noise. After you confirm, the **Flywheel** card moves from `proposal_ready` to `confirmed_no_join` (until the outcome source is wired) or `resolving` (once real outcomes arrive). Every state is derived from the ledger — never a writable flag — so it can only reflect what is actually true. ## What this proves — and what it does not **Proves.** You can go from raw traffic to a single, inspectable, confirmed workflow without a configuration form: connect, read the two a-ha payloads, read the proposed plan over the API, and confirm what "it worked" means — with every drafted value chipped as a suggestion and nothing acting on your requests until you confirm. **Does not prove.** A confirmed workflow is a starting line, not a result. It does **not** claim any improvement to your app: confirming an objective turns learning *on* for that workflow, but a lift is only ever claimed later, behind a holdout, with its scope, interval and `n` stated. Turning a confirmed objective into captured corrections, a tested change and a live-confirmed comparison is covered by the [correction quickstart](/docs/guides/correction-start) and the [application-CI and live-confirmation quickstart](/docs/guides/application-ci), each published as it ships. ## Next steps - Read the [Discovery concept](/docs/concepts/discovery) for the two a-ha moments, the channel ladder, and the "proposed never teaches" law in full. - Register [what "it worked" means](/docs/guides/flywheel-objectives) for the workflow you confirmed. - Capture your [first reviewer correction](/docs/guides/correction-start) against it. --- # Declare what "it worked" means source: https://docs.ultrarouter.io/docs/guides/flywheel-objectives (https://docs.ultrarouter.io/docs/guides/flywheel-objectives.md) Before the outcome ledger can tell you whether an answer was any good, something has to say what "good" means for that kind of answer. ultrarouter will infer a lot of things. It will never infer this one. `POST /v1/artifact-types` registers an **artifact type** — a customer-named kind of output (`thesis`, `tax_plan`, `article`) — together with its **objective**: the metric that resolves it, the unit, which direction is good, how it resolves, who resolves it, and the window it resolves within. Every artifact you later create with that `artifact_type` pins the objective version that was current at the time, so a change to your definition never silently re-scores history. The Flywheel is a **Phase-10 product under construction**. This endpoint, the artifact ledger and the outcome API are live; the learning, calibration and lift surfaces they feed are not. `GET /v1/flywheel/status` reports its learning fields as explicitly unavailable while it is collecting — a collecting state never implies an observed improvement. ## Prerequisites - **A key with the `outcomes:write` scope.** New keys are granted it by default; a key created with an explicit `scopes` list may omit it. Check with `GET /v1/key`. - **Base URL and key exported:** ```bash export UR_BASE_URL="http://localhost:8080" export UR_API_KEY="ur_sk_live_…" # ur_sk_live_* or ur_sk_test_* ``` ## Register a type The example below is the "investment thesis" case: a forecast whose claims resolve true or false against a company filing, within twelve months. ```bash exec curl -sS --fail -X POST "$UR_BASE_URL/v1/artifact-types" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "thesis", "archetype": "forecast", "objective": { "metric": "claim_resolution", "unit": "boolean", "direction": "true_is_good", "resolution_criteria": "company filing vs stated claim", "source": "analyst", "window": "P12M" } }' > /dev/null echo "ok: artifact type 'thesis' registered" ``` The response is the **current objective version**: ```json { "artifact_type": { "object": "artifact_type", "id": "atyp_01J…", "name": "thesis", "archetype": "forecast", "version": 1, "objective": { "metric": "claim_resolution", "unit": "boolean", "...": "..." }, "created_at": "2026-09-14T00:00:00.000Z" } } ``` ## Versions are append-only Sending the **same** `archetype` and `objective` again is an idempotent no-op: you get version 1 back, with the same `id`, and no row is added. That is why the call above is safe to keep at the top of a bootstrap script or a CI job — re-running it costs nothing and changes nothing. Change **anything** in the objective (or the archetype) and the registry appends **version 2**. Version 1 is never rewritten, and every artifact that pinned it keeps pointing at it. There is no update path and no delete path: the table itself denies them. ```bash # window P12M → P6M: this appends version 2 curl -sS -X POST "$UR_BASE_URL/v1/artifact-types" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "thesis", "archetype": "forecast", "objective": { "metric": "claim_resolution", "unit": "boolean", "direction": "true_is_good", "resolution_criteria": "company filing vs stated claim", "source": "analyst", "window": "P6M", "predicate_id": "equals" } }' ``` ## The objective fields | Field | Required | What it is | | --- | --- | --- | | `metric` | yes | One of `claim_resolution`, `oracle_delta`, `correction_rate`, `acceptance_rate`, `engagement_rate`, `task_success`. A closed set — an unknown metric is a `400`, never a stored guess. | | `unit` | yes | What the metric is counted in (`boolean`, `ratio`, `usd`, …). | | `direction` | yes | Which way is good (`true_is_good`, `higher_is_better`, `lower_is_better`, …). | | `resolution_criteria` | yes | Free text: how a human or a job decides. Redact it before you send it — it is stored as you wrote it and it is never a place for a document body. | | `source` | yes | Who or what resolves it (`analyst`, `filing`, an oracle name). | | `window` | yes | An ISO-8601 **duration** (`P12M`, `P30D`, `PT6H`) — how long resolution may take, not a date. | | `predicate_id` | no | `equals`, `within_tolerance`, `ge`, `le` or `resolved_by_source` — a machine predicate over the resolution. | | `alternatives` | no | Named alternatives for computable comparisons (`alternative_id` on an outcome event). | Every field the plan names is required. A half-declared objective is a `400`: a registry row that only half-says what "good" means is worse than no row at all. ## Use it Reference the type by `name` when you create an artifact. The artifact stores the `artifact_type_version_id` that was current at that moment: ```bash curl -sS -X POST "$UR_BASE_URL/v1/artifacts" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "thesis:AAPL-2026Q4", "archetype": "forecast", "artifact_type": "thesis", "claims": [ { "key": "eps_beat", "claim_type": "earnings_direction", "stated_confidence": 0.7, "resolves_by": "2026-12-31T00:00:00Z" } ] }' ``` From there, outcomes land on the artifact or on a specific claim — see the [Flywheel API reference](/docs/api/flywheel) for `POST /v1/artifacts/:id/outcomes`, `POST /v1/outcomes/batch` and `GET /v1/outcomes/pending`. ## Bringing history first The registry above is the direct path: you name the objective yourself. There is a second path, for a different starting point — a workspace that already has **live traffic on its call types but no outcome events yet**, and wants to [import its history](/docs/guides/import-history) before it waits for new outcomes. An import binds to a call type's **confirmed objective**. On a workspace with no outcome events, nothing has been measured, so the usual route to a confirmed objective is circular: discovery only proposes the one workflow your spend ranks first, and confirming any other workflow refuses `no_feasible_objective` because no metric has any evidence yet. The history that would supply the evidence is exactly the history you cannot import until an objective is confirmed. Two session actions break the circle. Both require a signed-in owner, admin or developer (the `flywheel.write` role) — never an API key. ### 1. Propose the named workflow `POST /v1/flywheel/plans` with the call type you want to set up. The server derives the call type's workflow from your traffic and appends a proposal — the same kind of row discovery would append, so the confirm step is unchanged. ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/plans" \ -H "Content-Type: application/json" \ -H "Cookie: $UR_SESSION" -H "X-UR-Workspace: $UR_WORKSPACE_ID" \ -d '{ "call_type_id": "ct_01J…" }' # → 201 { "plan_id": "lplan_…", "workflow_id": "wfc_…", "call_type_id": "ct_01J…", # "version": 1, "call_type_bindings": [ { "call_type_id": "ct_01J…", "role": "terminal" } ] } ``` The `plan_id` is exactly what the confirm route below accepts, and `call_type_bindings` is the full scope the confirmation covers, so you can review it before you confirm. Each refusal writes nothing and names itself: | Status | Reason | Meaning | | --- | --- | --- | | `404` | `call_type_not_found` | Not one of this workspace's call types. A call type from another workspace reads the same — the tenant boundary hides it. | | `409` | `unknown_workflow` | No workflow candidate carries this call type. Configured call types **with traffic** do get candidates; one with no traffic in the last 30 days does not. | | `409` | `ambiguous_workflow` | The call type belongs to more than one derived workflow. The details list the candidate `wfc_…` ids; pass one as `workflow_id`. | | `409` | `proposal_pending` | The workflow already has a pending proposal. Its `plan_id` is in the details — confirm that one. One pending proposal per workflow. | | `409` | `workflow_confirmed` | The workflow already has a confirmed plan. A confirmed plan changes only through discovery's material-change path, never a re-propose. | An explicit propose is a human act, so a `declined`, `deferred` or `stale` latest row does **not** block a fresh proposal — the one-pending-proposal-per-workflow rule is what bounds it. (The seven-day budget governs the proposals discovery surfaces automatically; it does not gate your own request.) A propose runs no paid analysis. ### 2. Choose the objective at confirmation Confirm the proposal and pass the metric you want to measure, since none can be recommended yet: ```bash curl -sS -X POST "$UR_BASE_URL/v1/flywheel/plan/lplan_…/confirm" \ -H "Content-Type: application/json" \ -H "Cookie: $UR_SESSION" -H "X-UR-Workspace: $UR_WORKSPACE_ID" \ -d '{ "objective_metric": "claim_resolution" }' # → 200 { "object": "learning_plan_confirm", "objective_source": "declared", # "capture_levels": { "ct_01J…": "unavailable" }, … } ``` `objective_metric` is one of the six metrics in the table above. A present value **replaces** the (absent) recommendation; the confirm response reports `objective_source: "declared"` so the selection's provenance is durable and honest. Three things stay true: - **Declared means confirmed, not measured.** Choosing a metric fabricates no observation and no qualification. The plan compiles **below teach** — its `capture_level` is `unavailable` (or `rung_below_observed`) until the metric's channel is actually **observed and qualified** by receipts. A confirmed objective never teaches on the strength of a declaration alone. - **Objective selection is separate from event provenance.** `objective_source` describes how the objective was chosen (`declared`, or `observed` / `inferred` when you pass no metric and a recommendation or site-brief seed is taken). It does not touch an outcome event's own `source` — imported history stays `source: imported`. - **A later confirm preserves your choice.** Re-confirming the same workflow without `objective_metric` keeps a previously declared metric; it is not silently replaced by a fresh recommendation. If you omit `objective_metric` and nothing has been measured, the confirm refuses `no_feasible_objective` and names `objective_metric` as the way forward. With the objective confirmed, your history [imports bound to it](/docs/guides/import-history#4-bind-history-to-a-call-type-so-its-verdicts-can-count): the import pins the call type's confirmed objective version, and a workflow-execution import's terminal decisions land on that metric's channel (for `claim_resolution`, `delivered` → `resolved_true` and `held` → `resolved_false` on `resolution`). ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `403 permission` | The key is missing `outcomes:write`. Check `GET /v1/key`; mint or roll a key that includes it. | | `400` naming `metric` | The metric is outside the closed set. Pick one of the six above. | | `400` on `window` | `window` is a duration, not a date — `P12M`, not `2027-01-30`. | | `400` with a missing field | Every field except `predicate_id` and `alternatives` is required. | | The version did not increase | The objective you sent is byte-equivalent to the current version (key order does not matter). That is the idempotent no-op, working as intended. | --- # Import history source: https://docs.ultrarouter.io/docs/guides/import-history (https://docs.ultrarouter.io/docs/guides/import-history.md) You do not have to wait for new traffic to get value from the Flywheel. `POST /v1/imports` brings your history in — a usage export from your provider dashboard, or intact request/ response traces with their outcomes — and returns an honest **capability report** before anything is analysed. Every row that cannot be consumed is **rejected with a named reason**, and a rejected row is **repairable**: fix it and resume with the same import id. Import is deliberately conservative about what a file can prove. That conservatism is the feature. An upload authorizes **no paid analysis**. Importing history creates exposure summaries and, with consent, redacted trace envelopes — it never runs a model, never bills you, and never claims a quality result a usage export cannot support. Analysis is a separate, budgeted, explicitly-authorized step. ## Two import classes, and the difference is the point The adapter you choose pins the import **class**, and the class caps what the import can ever claim: - **Usage-only** (`usage_summary`) — per-model spend and exposure summaries. It reveals which models you send traffic to, what they cost, and which fields were missing. It can **never** manufacture a prompt, an output, a quality figure or a replayable case — its report is forced to `replayable: false`, regardless of what a file claims. - **Intact traces** (e.g. `openai_chat_jsonl`) — full `{request, response, occurred_at, outcome?}` records. These additionally carry bodies, stored **only** under `corrections_consent` and **redacted on ingest**. Only then can candidate models be replayed against your real cases. Imported records are always `source: imported` with `exposure_arm: imported` — they inform discovery and can seed distillation, but they are **never** a live-lift contrast. See [Holdout and lift](/docs/concepts/holdout-and-lift) for why the arm matters. ## Prerequisites - **The stack running.** Locally, `pnpm dev:e2e` boots the gateway on `http://localhost:8080`. These endpoints are part of the learning preview, which is not on the invite-only hosted gateway (`https://api.ultrarouter.io`) yet — run the local stack or point at your own deployment. - **A key with `outcomes:write`** (or an owner/admin session with `flywheel.import`). New keys are granted the scope by default; check with `GET /v1/key`. ```bash export UR_BASE_URL="http://localhost:8080" export UR_API_KEY="ur_sk_live_…" # ur_sk_live_* or ur_sk_test_* ``` ## 1. Preview before you commit `"mode": "preview"` returns the full capability report with **nothing stored** — the honest way to see the class truth, the rejection counts and the missing fields before you ingest. ```bash curl -sS -X POST "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{ "format": "usage_summary", "mode": "preview", "records": [ { "model": "openai/gpt-4o", "requests": 1200, "input_tokens": 480000, "output_tokens": 96000, "cost_usd": 18.40 } ] }' # → { "import": { "import_class": "usage_only", "status": "preview", # "report": { "class_truth": "…usage, spend and missing fields; it cannot manufacture # prompts, quality results or replayable cases", "replayable": false, … } } } ``` The `report.class_truth` sentence states, in plain language, exactly what this import can and cannot prove — and a usage-only import's is always the conservative one. ## 2. Commit, and read the per-row receipt A commit ingests. Ingest is **idempotent per record content hash** (resume never double-ingests), and a malformed row is **rejected with a named reason** — never guessed. Below, one row is missing its `model`, so the good row is accepted and the bad one is reported: ```bash exec # Import a two-row usage history where ONE row is malformed (no `model`). The good row is # accepted; the bad one is rejected and NAMED in the receipt. Zero model calls. imp=$(curl -sS --fail -X POST "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{"format":"usage_summary","records":[ {"model":"openai/gpt-4o","requests":12,"input_tokens":4800,"output_tokens":960,"cost_usd":0.21}, {"requests":3,"input_tokens":900,"output_tokens":120,"cost_usd":0.04}]}') id=$(printf '%s' "$imp" | python3 -c 'import sys,json; d=json.load(sys.stdin)["import"]; assert d["accepted_count"]==1 and d["record_count"]==2, d; assert d["import_class"]=="usage_only", d; print(d["id"])') echo "ok: import $id accepted 1/2 rows; the malformed row is named in report.reasons" # REPAIR: fix the row and resume with the SAME import_id. Idempotent per content hash, so # the already-good row dedupes and only the repaired row lands — never double-counted. curl -sS --fail -X POST "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d "{\"format\":\"usage_summary\",\"import_id\":\"$id\",\"records\":[ {\"model\":\"openai/gpt-4o\",\"requests\":12,\"input_tokens\":4800,\"output_tokens\":960,\"cost_usd\":0.21}, {\"model\":\"openai/gpt-4o-mini\",\"requests\":3,\"input_tokens\":900,\"output_tokens\":120,\"cost_usd\":0.04}]}" \ | python3 -c 'import sys,json; d=json.load(sys.stdin)["import"]; assert d["accepted_count"]>=1 and d["duplicate_count"]>=1, d; print("ok: repaired row accepted; the already-good row deduped")' ``` **Expected state.** The first call returns `accepted_count: 1`, the malformed row counted under `rejected_count`, and `report.reasons` maps the reason (a usage row requires a `model`) to a count. The repair call, resumed with the same `import_id`, accepts the fixed row and reports the unchanged row as `duplicate` — resume never double-counts. ## 3. Intact traces need consent To bring full request/response bodies (the only thing that unlocks replay), use an intact-trace adapter and grant `corrections_consent` first. Without consent, a commit is refused; a **preview** still returns the capability report without storing a body: ```bash # Preview first — no consent needed to see what WOULD be stored. curl -sS -X POST "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{ "format": "openai_chat_jsonl", "mode": "preview", "jsonl": "{\"request\":{\"model\":\"openai/gpt-4o\",\"messages\":[]},\"response\":{},\"occurred_at\":\"2026-06-01T00:00:00Z\"}" }' # → report.import_class = "intact_trace", bodies_stored = 0 (preview never stores) ``` Grant consent on the **Flywheel** screen (owner-only, audited); revoking it stops new derivations and tombstones existing exports — it never rewrites what was already recorded. ## 4. Bind history to a call type so its verdicts can count History with task verdicts (`"outcome": { "channel": "resolution", "kind": "resolved_true" }`) can move a workflow's **readiness**, the count of resolved outcomes a workflow needs before the Flywheel will test an improvement for it. For that, the import must say **which call type the history belongs to**. Name it with `call_type_id` (the `ct_…` id from `GET /v1/call-types`). The call type must have a **confirmed** objective: confirm its learning plan first. An objective that is only proposed never teaches. **Bringing history first.** If the workspace has traffic but no outcome events yet, there is nothing to confirm the usual way — discovery only proposes the spend-leading workflow, and a confirm with no measured metric refuses `no_feasible_objective`. Break the circle with two session actions (owner / admin / developer, never an API key): propose the named workflow with `POST /v1/flywheel/plans` `{ "call_type_id": "ct_…" }`, then confirm it choosing the metric your history populates — `POST /v1/flywheel/plan//confirm` `{ "objective_metric": "claim_resolution" }`. The confirm reports `objective_source: "declared"`. **Declared means confirmed, not measured:** the plan compiles below teach until the channel is observed and qualified; choosing a metric fabricates no observation. For a workflow-execution import, `claim_resolution` is the metric its terminal decisions populate (`delivered` → `resolved_true`, `held` → `resolved_false` on the `resolution` channel). See [Declare what "it worked" means](/docs/guides/flywheel-objectives#bringing-history-first) for the full flow. ```bash curl -sS -X POST "$UR_BASE_URL/v1/imports" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{ "format": "openai_chat_jsonl", "call_type_id": "ct_01J…", "records": [ { "request": { "model": "openai/gpt-4o", "messages": [ { "role": "system", "content": "You triage support tickets." }, { "role": "user", "content": "My order never arrived." } ] }, "response": { "choices": [ { "message": { "content": "Refund issued." } } ] }, "occurred_at": "2026-06-01T00:00:00Z", "outcome": { "channel": "resolution", "kind": "resolved_true" } } ] }' # → { "import": { "call_type_id": "ct_01J…", "artifact_type_version_id": "atyp_01J…", # "report": { "binding_truth": "Bound to call type 'ct_01J…' (confirmed objective version …) …" } } } ``` **Expected state.** The import returns the call type it is bound to and the confirmed objective version it was bound under. `report.binding_truth` says, in one sentence, what that means. Each accepted record becomes an artifact of that call type, stamped with its confirmed objective and with the same **situation** your live traffic from that call site gets. The situation is derived from the request's shape: its instruction template, tools and response format. So history lands in the same workflow unit as the calls you make through the gateway. **What still stands between the upload and readiness.** Each import batch is its own **verdict source**. A batch cannot prove on its own that its verdicts are right, so they stay held until someone who is not the source checks a sample of them. That check is a **blind admission audit**: 1. Open it with `POST /v1/flywheel/source-audits` and `{ "verdict_source": "import:", "purpose": "admission" }`. You must use a signed-in owner or admin session. 2. The response lists 40 sampled artifact ids, without the batch's verdicts. Label each one through the outcome API: a `resolution` event on the artifact. 3. Grade the audit with `POST /v1/flywheel/source-audits//grade`. With zero mislabels the batch is **admitted**. From then on, its history counts toward that call type's readiness. After the next scheduler pass (every five minutes by default), `GET /v1/flywheel/moat-next` shows the new count, for example "needs 155 more resolved task outcomes (45/200)". Only history recorded up to the audit counts. Records added to the batch later are stamped, but they do not count on that audit. **Rules that keep a batch honest:** - A batch is bound once, when it is created. A resume (`import_id`) keeps that binding without repeating `call_type_id`. Naming a different call type on a resume is refused. - If the call type's objective is re-confirmed, resuming an older batch is refused as stale. Start a new import; it binds to the current objective. - A usage-only file cannot be bound. It creates no artifacts. - An import **without** `call_type_id` is unbound. Its report says so, and its verdicts never count toward any workflow's readiness, even if the batch is admitted. Imports created before this feature are unbound too, and they are never bound after the fact. To bind that history, upload the same file again as a **new** import naming `call_type_id`. The new batch creates its own bound artifacts next to the old ones and needs its own admission audit. **Re-confirming changes the measurement — and your bound history cannot follow it today.** Re-confirming a workflow with a *changed* measurement mints a new confirmed objective version, and history bound to the old version goes **stale** against it (that is the law, not a bug: a new measurement must not silently re-score old evidence). There is **no re-import recovery** for that history today. For a usage or intact-trace (`openai_chat_jsonl`) import, whose artifact identity is the record content hash, you can upload the same file again as a new import and it binds to the current objective. But a **workflow-execution** import (`workflow_execution_jsonl`) groups your gateway's own request rows, and the first import already **owns** those member requests — re-importing the same executions under the new objective is refused `request_already_grouped` (the FLW-106 membership law: one request belongs to one active artifact). So choose the metric deliberately at the first confirm: a later measurement change leaves the bound batch stale, and the same executions will not re-import under it. (A lawful versioned re-analysis that preserves an unchanged measurement is a planned capability, not a promise here.) **Name call types the way your traffic does.** In a `workflow_execution_jsonl` record, `lever_call_type` and each call's `call_type` may be the `ct_…` id, the slug you send in `X-UR-Call-Type`, or one of its aliases. The importer resolves each name to the id before it checks the lever against the batch's `call_type_id` and each call against its stored request, exactly as the gateway resolves your header. A name that matches no call type in the workspace is refused by name (`lever_call_type_mismatch` / `request_call_type_mismatch`). ## 5. Cancel a non-terminal import A `preview` or `running` import can be cancelled. Cancellation releases nothing to spend (imports reserve no budget) and preserves per-record idempotency, so a later resume picks up cleanly: ```bash curl -sS -X POST "$UR_BASE_URL/v1/imports/imp_01J…/cancel" \ -H "Authorization: Bearer $UR_API_KEY" # → { "import": { "status": "cancelled", … } } · a terminal import is a 409 ``` ## The dashboard receipt The **Flywheel** screen renders the same receipt: each rejected row's state is named, and a reviewer event that could not be joined (a mismatched draft version) is a first-class **dead letter** you re-drive with `POST /v1/flywheel/reviewer/repair`. A repair re-runs the stored event; it never rewrites a recorded consequence. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `403` on `POST /v1/imports` | The key is missing `outcomes:write` (or the session lacks `flywheel.import`). Check `GET /v1/key`. | | `403` committing intact traces | An intact-trace **commit** needs `corrections_consent`. Grant it, or send `"mode": "preview"` to inspect the report without storing a body. | | Rows all under `rejected_count` | A usage row requires a `model`. Preview and read `report.reasons` for the named counts, fix the rows, resume with the same `import_id`. | | Verdict/quality expected but absent | A usage-only import is `replayable: false` by construction — it carries no quality signal. Bring intact traces (with consent) to unlock replay. | | `409` cancelling | The import is already terminal (`completed` / `cancelled` / `failed`). There is nothing to cancel. | | `404 call_type_not_found` | The `call_type_id` is not one of this workspace's call types. List them with `GET /v1/call-types` and use the `ct_…` id. | | `409 call_type_unconfirmed` | The call type has no confirmed objective yet. Confirm its learning plan first, then import. | | `400 binding_requires_intact_trace` | `call_type_id` was sent with a usage-only file. Usage summaries create no artifacts; bind intact traces instead. | | `409 binding_conflict` | A resume named a different call type than the batch's, or tried to bind a batch that was created unbound. Resume without `call_type_id`, or start a new import. | | `409 binding_stale` | The call type's objective was re-confirmed after this batch was bound. Start a new import; it binds to the current objective. | | Admitted batch, readiness unchanged | The batch was imported without `call_type_id` (its `binding_truth` starts with "Not bound"). Upload it again as a new import naming the call type, then audit that batch. | ## What this proves — and what it does not **Proves.** You can bring history in on Day 1 without touching your traffic — usage exposure and spend with no consent, redacted intact traces with consent — with the class truth stated up front, per-record idempotency, and every rejected row named and repairable. History bound to a confirmed call type and admitted by a blind audit counts toward that workflow's readiness, in the same unit as its live traffic. **Does not prove.** A usage-only import proves **nothing** about quality — it cannot manufacture a prompt or a result. An intact-trace import gives you replayable cases, not a verdict; the verdict is a separate, budgeted analysis you authorize. Imported data is `exposure_arm: imported` and is **never** a live-lift contrast. An admission audit bounds the batch's error rate, at roughly 9 % after zero mislabels in 40 samples. It does not prove every verdict right. Readiness only decides when an improvement is worth testing; the improvement still has to pass its own screen and a live comparison. ## Next steps - [Record outcomes](/docs/guides/outcomes) — the live capture path the import backfills. - [Plan a model migration](/docs/guides/migration-start) — where a usage import becomes a scoped decision. - [The training-ready export](/docs/guides/training-export) — what leaves the ledger as a dataset you own. --- # Learning controls source: https://docs.ultrarouter.io/docs/guides/learning-controls (https://docs.ultrarouter.io/docs/guides/learning-controls.md) A call type that learns from its own verified episodes keeps a history of **versions** — each one a small, immutable set of lessons distilled from episodes your application reported as passed or failed. The learning controls decide which version your agent is **served**, and let you see and steer every step. A newly learned version is a **candidate**. Your promotion policy decides when it replaces the **active** version. With `evaluate`, the candidate serves only its randomly assigned share of eligible episodes during the comparison; the other arm uses the current version. Promotion makes the candidate the active version for subsequent traffic: | Promotion mode | What makes a candidate active | | --- | --- | | `evaluate` (default) | A prospective randomized evaluation against the active version, under a rule fixed when the evaluation starts. | | `manual` | You promote it (or reject it). | | `immediate` | Publication — the newest version serves at once. | Negative or inconclusive evaluations keep the active version. Nothing on this page claims that a learned version improves your agent — an evaluation is exactly how you find out, on your own traffic, with an interval you can read. ## Prerequisites - **Learning available for your workspace.** Learning can be switched off for a workspace on a deployment (it is off by default on a new deployment). Then every learning route answers `404 not_found`, exactly like an unknown route, and the dashboard shows no Learning pages; routing and compression keep working. Ask your ultrarouter operator to enable learning for the workspace. - **Workspace Learning opt-in on.** As the owner, choose **Allow learning** on the call type's dashboard **Learning** page or in the **Learn** onboarding flow. This grants both **corrections consent** to store episode content and **knowledge consent** to use what it learns. Corrections consent alone is insufficient. The equivalent request is `PUT /v1/workspaces/{id}/self-learning` with `{"enabled":true}` from a signed-in owner session; a bearer API key cannot grant these permissions. - **A reflection model your workspace owns** — a model served by one of your custom providers (for example a local subscription gateway) or a provider you stored your own key for. Learning never calls a platform-keyed model and has no paid fallback. Your workspace's billing funding mode must allow calls on your own keys (`byok`). - **An owner or admin session, or an API key with `config:write`.** Reading needs `config:read` (or any membership). ## Turn learning on ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/$CALL_TYPE_ID/learning/enable" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reflection_model": "local/claude-sonnet" }' ``` The response carries the resulting status. The promotion policy starts from these defaults — change any of them with a `promotion` block, before an evaluation starts: | Field | Default | Meaning | | --- | --- | --- | | `mode` | `evaluate` | How a candidate becomes active. | | `objective` | `success` | `success`: a higher task success rate. `cost_noninferior`: a lower mean cost per episode with success not worse than `noninferiority_margin_pp`. | | `min_verified_per_arm` | `30` | Compatibility name: under `lpr2`, enrollment closes once both arms have this many enrolled episodes. Their outcomes are then awaited. | | `max_verified_per_arm` | `200` | Enrollment also closes when either arm reaches this cap. The selected episodes are frozen before outcomes are inspected. | | `alpha` | `0.05` | One-sided significance level of the interval on the success difference. | | `min_effect_pp` | `0` | `success`: the smallest improvement (percentage points) worth promoting. | | `noninferiority_margin_pp` | `5` | `cost_noninferior`: the success margin. | | `candidate_share` | `0.5` | The share of new enrolled episodes assigned the candidate while enrollment is open. | | `max_duration_hours` | `720` | The original deadline for collecting episodes and receiving their outcomes. It never resets. | Once an evaluation starts, its rule is frozen with it (and hashed): changing the policy affects the next evaluation only. ### Choose the learner recipe `recipe` picks how a reflection learns. Each field is optional; an omitted field keeps the original learner's value: | Field | Values | Meaning | | --- | --- | --- | | `validator` | `elv1` (default), `elv2` | `elv2` checks each proposed lesson against the evidence it cites, and keeps documented tool constants with their tool. | | `generation` | `bundled` (default), `atomic` | `atomic` writes one complete rule per lesson, with its conditions, exceptions and required tools. | | `observations` | `legacy` (default), `elo2` | `elo2` shows the reflection error-to-correction steps, passed-case results and labelled user turns within the same budget. | ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/$CALL_TYPE_ID/learning/enable" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reflection_model": "local/claude-sonnet", "recipe": { "validator": "elv2", "generation": "atomic", "observations": "elo2" } }' ``` The recipe is stored on the call type's episode-memory setting, so `GET …/learning` (`recipe`) and `GET …/episode-memory` (`setting.lessons.recipe`) always report the same one. Enabling again without `recipe` keeps the current one; sending the all-default recipe returns to the original learner. A new recipe applies from the next reflection; each version records the recipe hash it was built with. ### What `elv2` admits `elv2` gives each proposed lesson two separate verdicts: - **Evidence support.** The lesson must cite known cases, and **every tool it names** must be offered in, or called by, at least one cited case. Otherwise it is `needs_stronger_evidence` (`named_tool_not_in_cited_evidence`). An uncited case never grounds a tool. - **Value handling.** Every literal is classified by its role in everything the reflection saw, cited or not. A copied identifier, user value or answer is never admitted. A quoted message that appears only in tool outputs, never in a user turn, a tool argument or an answer, and holds no task-specific value is accepted (for example `'Booking not found'`). A constant is admitted only when a tool's schema or documentation states it **and** the lesson names that tool somewhere in its text. A constant documented for one tool never vouches for a lesson that names only another. A tool name that reads as an identifier (`get_user_tickets`, `displayCarStatus`) names that tool wherever it appears. A tool named by an ordinary word (`add`, `find`) is named only when the lesson writes it as a call (`add(`), with the `api.` prefix, in backticks, or as the whole value of an assignment or call argument. Elsewhere the word is ordinary language: in "find the id with api.get_user_tickets()", only `get_user_tickets` is named. The admission rules carry a revision number that is folded into the recipe hash, so a version built under a newer revision reports a different `epr2_` hash. ### Document text-protocol tools Agents that call tools in code (`api.fillFuelTank(...)`) instead of sending native `tools[]` give the learner no schema to read constants from. Supply the specs once per call type, as OpenAI-style function definitions: ```bash curl -sS -X PUT "$UR_BASE_URL/v1/call-types/$CALL_TYPE_ID/learning/tool-documentation" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tools": [ { "type": "function", "function": { "name": "fillFuelTank", "description": "The tank holds at most 50 liters.", "parameters": { "type": "object", "properties": { "fuelAmount": { "type": "number" } } } } } ] }' ``` - At most 256 specs with unique names, and a body of at most 1 MiB. - Each spec is stored as a bounded summary: its name, a `tsf1_` schema fingerprint, its parameter names and the constants it documents (enum / const / default values and numbers or quoted tokens from its description). The description prose itself is never stored. - `PUT` replaces the stored set. Sending the same set again returns `changed: false`, and an empty `tools` list clears it. `GET` returns what is stored. - Changing it needs `config:write` (or an owner/admin session); reading needs `config:read`. Every change is audited with counts, digests and fingerprints only. - Revoking corrections consent erases it with the rest of the learning data. The documentation only affects `elv2` and only from the next reflection. It never grounds a tool for evidence support: that still needs a cited case. ## How an evaluation works New evaluations use `lpr2`. While enrollment is open, each eligible episode is assigned the candidate or active version by a stable hash of its episode key. The assignment is recorded before the selected lessons are read, and continuing steps keep that assignment. A retrieval fallback serves no lessons on that request; it does not remove an enrolled episode's outcome. Enrollment closes at the configured episode-count threshold. The selected episode IDs are frozen before their outcomes are inspected. New episodes then use the active version, while the selected episodes finish under their original assignments. Only whole-episode outcomes your application reports through a teaching channel count, such as a verifier result, resolved ticket or accepted correction. Self-ratings and helpfulness labels never decide a promotion. Selected episodes with no verdict stay `unknown`; they are not dropped or replaced. The comparison waits for their outcomes within its original deadline. Missing outcomes at that deadline keep the incumbent active. This comparison concerns recorded, enrolled episodes; it does not establish a guarantee for all incoming traffic or future tasks. Once the selected outcomes are complete, the gateway computes the one-sided Newcombe interval on `candidate − active` success and decides once: - **promoted** — the lower bound is above 0 and the improvement is at least `min_effect_pp`; - **rejected** — the candidate is significantly worse; - **retained** / **inconclusive** — anything else; the active version stays. Historical `lpr1` evaluations retain their original verified-only rule and result. An unfinished evaluation using that retired rule is stopped conservatively; it is not silently reinterpreted as `lpr2`. ```bash curl -sS "$UR_BASE_URL/v1/call-types/$CALL_TYPE_ID/learning/evaluations" \ -H "Authorization: Bearer $UR_API_KEY" ``` A newer candidate that arrives while an evaluation runs waits for it; the newest waiting candidate is evaluated next and any older waiting one is marked `superseded`. ## Read the status ```bash curl -sS "$UR_BASE_URL/v1/call-types/$CALL_TYPE_ID/learning" \ -H "Authorization: Bearer $UR_API_KEY" ``` | `state` | Meaning | | --- | --- | | `collecting` | On, waiting for enough verified episodes to learn from. | | `learning` | A reflection is due or an evaluation is running. | | `paused` | Paused by you; the active version keeps serving. | | `disabled` | Turned off; no lessons are served. | | `unavailable` | Consent is off, the reflection model has no workspace-owned provider (see `reflection.provider.reason`), or reflections failed repeatedly. | | `quota_exhausted` | Your provider refused for quota; retried with a growing delay (15 min, doubling, at most 6 h). | | `budget_exhausted` | The learning budget is spent; retried the same way. | | `off` | Learning is not enabled for the call type. | The `evidence` block separates what **arrived** (`recorded`, `verified`, `unknown`) from what the learner has **reflected over** (`consumed`) and what is in the **active** version (`incorporated_active`); `backlog` is verified but not yet reflected over. `reflection.next` says whether the next scan will reflect and why not. `usage` reports reflection tokens separately from cash. `outcome_receipts` counts the outcomes your application reported through `POST /v1/requests/{id}/outcomes` for this call type's requests, by state: `pending` (accepted, the request is not recorded yet), `applied`, `rejected`, `failed`, `expired` (the request was never recorded in time), and `episode_pending` (applied, waiting for its episode to be recorded). A receipt counts once its request or its episode has been recorded for the call type. ## Steer it | Action | Request | | --- | --- | | Pause (stop learning and evaluating; keep serving the active version) | `POST …/learning/pause` | | Resume (also resets the retry counters) | `POST …/learning/resume` | | Disable (stop serving lessons and learning) | `POST …/learning/disable` | | Roll back to an earlier version | `POST …/learning/rollback` with `{ "version_id": "…" }` (omit it for the version active before the current one) | | Promote a version now | `POST …/learning/versions/{version_id}/promote` | | Reject a candidate | `POST …/learning/versions/{version_id}/reject` | | Version history / one version | `GET …/learning/versions`, `GET …/learning/versions/{version_id}` | Versions are immutable, so a rollback serves exactly the lessons that version had. Every control is recorded in your workspace audit log. Revoking corrections consent erases the versions, their history, the evaluations and the controls. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `409 conflict` `corrections_consent_required` | Either corrections or knowledge consent is missing. The owner chooses **Allow learning** in the dashboard, or uses the session-authenticated `PUT /v1/workspaces/{id}/self-learning` with `{"enabled":true}`. | | `400 invalid_request` `reflection_model_not_workspace_owned (platform_offer_only)` | The model is only reachable with a platform key. Register it on a custom provider or store your own key for its provider. | | `… (workspace_funding_not_byok)` | Set the workspace funding mode to `byok` (`POST /v1/workspaces/{id}/billing/funding`). | | `409 conflict` `learning_not_enabled` | Enable learning through this API before pausing, promoting or rolling back. | | `409 conflict` `no_rollback_target` | No earlier version was ever active — name a `version_id`. | --- # MCP servers for Claude Code and Codex source: https://docs.ultrarouter.io/docs/guides/mcp-servers (https://docs.ultrarouter.io/docs/guides/mcp-servers.md) ultrarouter ships two MCP servers. They do different jobs, and neither one changes how Claude Code or Codex itself works. | Surface | What it is | Needs a key | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | | **Documentation MCP** | `ultrarouter-docs`: search the docs, read a page, fetch the OpenAPI spec, get a curl example. | no | | **Management MCP** | `ultrarouter` (package `@ultrarouter/mcp`): check the connection, list call types, read learning status, versions and evaluations, read outcome receipts, pause, resume, disable and roll back learning. | yes, your API key | | Application inference routing | Your application's own model calls go through the gateway's OpenAI-compatible base URL. Configured in your application, not in an MCP server. | an application key | | Outcome instrumentation | Your application reports whether each task really succeeded (verifier, business rule, webhook, human review) through the SDK or `POST /v1/requests/{id}/outcomes`. See [Record outcomes](/docs/guides/outcomes). | a key with `outcomes:write` | Installing either MCP server does **not** route Claude Code or Codex traffic through ultrarouter, and it does **not** make the agent you are talking to improve. The management MCP reads and controls learning for **your application's** call types. An agent's own judgement that a task worked is not an outcome: the management MCP has no tool that reports one. Outcomes come from your application's verifier, business rule, webhook or human review. Both servers use the **stdio** transport: the host starts them as local processes. There is no hosted MCP endpoint. ## Install the management MCP The package is not published to npm yet. Build and pack it from an ultrarouter checkout, then install the tarball: ```bash pnpm --filter @ultrarouter/mcp build cd packages/mcp && pnpm pack # writes ultrarouter-mcp-0.1.0.tgz npm install -g ./ultrarouter-mcp-0.1.0.tgz ultrarouter-mcp --help ``` The server reads two environment variables when the host starts it: - `ULTRAROUTER_API_KEY` — an `ur_sk_live_…` / `ur_sk_test_…` key from **Settings → API keys**. Reading call types and learning needs `config:read` (or `config:write`); pause, resume, disable and rollback need `config:write`. Use a separate key from your application's. - `ULTRAROUTER_BASE_URL` — the gateway origin, default `http://localhost:8080` (the local stack). Check the key and URL without any host: `ultrarouter-mcp check`. ## Claude Code ### With the installer ```bash ultrarouter-mcp install --host claude # user scope: ~/.claude.json ultrarouter-mcp install --host claude --scope project # ./.mcp.json in this directory ultrarouter-mcp install --host claude --dry-run # show the diff, write nothing ``` User scope writes to `~/.claude.json`, or `$CLAUDE_CONFIG_DIR/.claude.json` when `CLAUDE_CONFIG_DIR` is set. The entry references the key by name; Claude Code expands it from the environment it was started in: ```json { "mcpServers": { "ultrarouter": { "type": "stdio", "command": "/path/to/node", "args": ["/path/to/@ultrarouter/mcp/dist/cli.js", "serve"], "env": { "ULTRAROUTER_API_KEY": "${ULTRAROUTER_API_KEY}", "ULTRAROUTER_BASE_URL": "${ULTRAROUTER_BASE_URL:-http://localhost:8080}" } } } } ``` Export `ULTRAROUTER_API_KEY` in the shell that starts `claude`, restart Claude Code, then check with `claude mcp get ultrarouter` or `/mcp`. A project-scope entry starts `ultrarouter-mcp serve` from `PATH` (no machine paths in a shared file), and Claude Code asks each person to approve it. ### With the plugin The plugin in `integrations/claude-code/ultrarouter` bundles the same server and an `integrate-ultrarouter` skill. Its `api_key` option is kept in Claude Code's secure storage instead of an environment variable. ```bash npm install -g ./ultrarouter-mcp-0.1.0.tgz # the plugin starts ultrarouter-mcp from PATH claude plugin marketplace add ./integrations/claude-code claude plugin install ultrarouter@ultrarouter-local ``` Then set the key inside Claude Code with `/plugin configure ultrarouter@ultrarouter-local`, or from the shell without putting it on the command line: ```bash printf '{"api_key":"%s"}' "$ULTRAROUTER_API_KEY" \ | claude plugin configure ultrarouter@ultrarouter-local --values-stdin ``` Until it is set, `check_connection` says so. Use the plugin **or** the installer, not both: both register an `ultrarouter` server. The local marketplace manifest only lets a checkout add the plugin; the plugin is not listed in any public marketplace. ## Codex ### With the installer ```bash ultrarouter-mcp install --host codex # user scope: ~/.codex/config.toml ultrarouter-mcp install --host codex --scope project # ./.codex/config.toml (trusted projects only) ``` User scope writes to `~/.codex/config.toml`, or `$CODEX_HOME/config.toml`. The installer appends one table and leaves every other line, comment and setting of the file as it was: ```toml [mcp_servers.ultrarouter] # Managed by ultrarouter-mcp. ULTRAROUTER_API_KEY is forwarded from your environment (env_vars), never stored here. command = "/path/to/node" args = [ "/path/to/@ultrarouter/mcp/dist/cli.js", "serve" ] env_vars = [ "ULTRAROUTER_API_KEY", "ULTRAROUTER_BASE_URL" ] ``` Codex starts MCP servers with a minimal environment and forwards only the variables listed in `env_vars`. Export `ULTRAROUTER_API_KEY` in the shell that starts `codex`, restart Codex, then check with `codex mcp get ultrarouter` or `/mcp`. `codex doctor` reports `env var ULTRAROUTER_BASE_URL is not set` as an optional issue when you use the default gateway; pass `--base-url ` to write a fixed URL instead. ### With the plugin The plugin in `integrations/codex/ultrarouter` uses the portable Agent Plugins layout (`plugin.json`, `mcp.json`, `skills/`) with the same skill: ```bash codex plugin marketplace add ./integrations/codex codex plugin add ultrarouter@ultrarouter-local ultrarouter-mcp install --host codex # gives the server its key ``` A plugin manifest cannot carry a secret, and Codex forwards no environment variables to a plugin's server. Run the installer as well: Codex then uses the installer's `[mcp_servers.ultrarouter]` entry, which forwards `ULTRAROUTER_API_KEY`, in place of the plugin's declaration. As with Claude Code, the local marketplace manifest is not a public listing. ## The documentation MCP The documentation MCP runs from an ultrarouter checkout (it is not published to npm either). Build it, then let the installer add it beside the management server: ```bash pnpm --filter @ultrarouter/docs-mcp... build ultrarouter-mcp install --host claude --with-docs --docs-repo "$PWD" ultrarouter-mcp install --host codex --with-docs --docs-repo "$PWD" ``` Its four tools — `search_docs`, `get_doc`, `get_openapi`, `example_request` — are described in [For agents](/docs/for-agents). It needs no key and reads no workspace data. ## Management tools | Tool | Calls | Effect | | ------------------------------- | ------------------------------------------------------------- | ------------------------------------------ | | `check_connection` | `GET /v1/key` | read: workspace id and key scopes, never the key | | `list_call_types` | `GET /v1/call-types` | read | | `search_knowledge` | `GET /v1/knowledge` | read | | `get_knowledge_item` | `GET /v1/knowledge/{id}` | read | | `get_learning_status` | `GET /v1/call-types/{id}/learning` | read | | `get_learning_versions` | `GET /v1/call-types/{id}/learning/versions[/{version_id}]` | read | | `get_evaluations` | `GET /v1/call-types/{id}/learning/evaluations` | read | | `get_outcome_receipt` | `GET /v1/outcome-receipts/{id}` | read | | `list_request_outcome_receipts` | `GET /v1/requests/{id}/outcome-receipts` | read | | `pause_learning` | `POST /v1/call-types/{id}/learning/pause` | stops reflecting; the active version keeps serving | | `resume_learning` | `POST /v1/call-types/{id}/learning/resume` | undoes a pause | | `disable_learning` | `POST /v1/call-types/{id}/learning/disable` | live traffic stops receiving lessons | | `rollback_learning` | `POST /v1/call-types/{id}/learning/rollback` | needs `version_id` and `confirm: true` | | `setup_guide` | none | static setup text | `disable_learning` and `rollback_learning` are marked destructive in their MCP annotations and ask Claude Code to show a person the permission prompt on every call. The server has no shell, SQL, credential-management, enable, promote, arbitrary-HTTP or outcome-reporting tool. Errors say what to fix: a rejected key (401), a missing scope or role (403), an id that is unknown or belongs to another workspace (404, indistinguishable by design), rate limits (429, with `Retry-After`), and an unreachable gateway. No message ever contains the key. ## Status and uninstall ```bash ultrarouter-mcp status # every host and scope ultrarouter-mcp uninstall --host claude # removes only the ultrarouter entry ultrarouter-mcp uninstall --host codex --with-docs # also removes ultrarouter-docs ``` Every install and uninstall is idempotent: running it again changes nothing. Before a real change the file is backed up next to itself (`*.ultrarouter-mcp-backup-", "cache_control": { "type": "ephemeral" } } ] }, { "role": "user", "content": "" } ] } ``` The breakpoint marks the boundary of the cacheable prefix: everything up to and including the annotated part is eligible to be cached. Put the stable content first (system prefix, long reference context, few-shot exemplars) and keep the volatile user turn after it. ## What the gateway does - **Anthropic** — the breakpoint is translated to `cache_control` on the corresponding Anthropic content block. A system part that carries a breakpoint is sent as an Anthropic system **block** (rather than a plain string) so the marker survives; a plain system prompt with no breakpoint is still sent as a plain string (unchanged behaviour). - **Every other provider** — OpenAI-compatible providers do not accept `cache_control`, so the gateway **strips it before dispatch and ignores it** (recorded as a structured log, never an error). Your request still succeeds; it simply is not cached. The provider must still meet its own minimum cacheable-prefix size (Anthropic requires a prefix on the order of a thousand tokens before it will cache it); a prefix below that threshold is sent normally and is not cached. ## Cost accounting Cost accounting reflects the provider-reported cache usage. Cache-read tokens are billed at the model's cache-read rate and cache-write (creation) tokens at the model's cache-write rate, with the remaining prompt tokens at the input rate; output tokens bill at the output rate. All amounts are integer micro-USD. The response `usage.prompt_tokens_details.cached_tokens` carries the provider-reported cache-read token count. ## Notes - Cache behaviour is provider-side and time-sensitive: a cache entry expires after its `ttl`, and whether a later call reads the cache depends on the provider. Treat the breakpoint as a hint, not a promise. - The dated Anthropic snapshot ids (for example `anthropic/claude-haiku-4-5-20251001`) resolve their price from the curated logical model, so a call to a dated id is priced correctly rather than recorded as zero. --- # Providers & BYOK source: https://docs.ultrarouter.io/docs/guides/providers-byok (https://docs.ultrarouter.io/docs/guides/providers-byok.md) ## First-class providers Seventeen providers are built in and resolve through one data-driven registry: `openai`, `anthropic`, `google` (Gemini), `deepseek`, `mistral`, `qwen`, `xai`, `kimi`, `zai` (Z.ai / Zhipu — GLM), `cohere`, `together`, `fireworks`, `deepinfra`, `groq`, `novita`, `cerebras`, and `bedrock` (Amazon Bedrock). An open-weight model (Llama / Qwen / DeepSeek) is multi-offer across the aggregators (Together / Fireworks / DeepInfra / Groq / Novita / Cerebras / Bedrock), so [routing](/docs/concepts/routing) can pick the cheapest or fastest offer. Browse the catalog with [`GET /v1/models`](/docs/api/models) (filter by `provider` / `capability`) and one model's offers with `GET /v1/models/{id}`. ## Amazon Bedrock Bedrock is a first-class provider with its own adapter: it authenticates with **AWS SigV4** (not a bearer key) and speaks the **Converse / ConverseStream** API. It exists to serve **open-weight models hosted in US regions** — including Chinese-lab open-weight models such as DeepSeek and Qwen — so a workspace whose [model policy](/docs/guides/model-policy) denies Chinese labs can still run those weights when they are US-hosted (see "Interaction with the model policy" below). **Credentials & region.** Bedrock's platform credential is composed from four environment variables (shared by the gateway and workers, injected per-service — never a single `UR_PROVIDER_BEDROCK_API_KEY`): - `UR_PROVIDER_BEDROCK_ACCESS_KEY_ID` and `UR_PROVIDER_BEDROCK_SECRET_ACCESS_KEY` — the AWS access key id + secret (required to enable Bedrock). - `UR_PROVIDER_BEDROCK_SESSION_TOKEN` — an optional STS session token for temporary credentials. - `UR_PROVIDER_BEDROCK_REGION` — the US region to invoke in; defaults to `us-east-1`. With no access key or secret configured, Bedrock is **keyless** and is skipped everywhere (catalog sync, routing, probes) with a structured log — never an error. Credentials and the AWS signature are never logged. **What is curated.** The catalog ships curated Bedrock offers for open-weight models served on-demand in US regions: DeepSeek-R1, Qwen3 32B, Qwen3 Coder 30B, and Llama 3.3 70B Instruct (a mirror of the existing Llama 3.3 70B logical model). Each offer records `hosting_jurisdiction: US` with its region; provenance (origin lab + open weights) comes from the logical model. Prices are curated from the public Bedrock pricing page (the Bedrock API exposes none). **Interaction with the model policy.** Because every curated Bedrock offer is US-hosted and open-weight, the [US-hosted open-weight exception](/docs/guides/model-policy#rules) admits it under a `us_only` policy that otherwise denies the offer's origin lab — while that lab's own first-party (e.g. China-hosted) offer stays denied. This is the mechanism behind running a Chinese open-weight model from a US host. ## Bring your own key (BYOK) Register your own provider keys per workspace with [`POST /v1/workspaces/:id/provider-keys`](/docs/api/provider-keys) (or in the dashboard `/providers` screen). BYOK keys participate in routing like first-class ones: - **`priority`** (ascending — tried first) and **`weight`** (load-spread among equal priorities). - **`byok_fallback`** — whether the platform key may be used if your keys fail. - **Health** — a save-time and hourly probe sets each key's health status. Secrets are **envelope-encrypted** (per-workspace data key wrapped by a versioned master key), decrypted only in memory at call time, and **never returned** by the API — responses show the last 4 characters only. A BYOK-served request sets `ur.byok = true`. ## Custom providers Register a custom OpenAI-compatible provider (e.g. a self-hosted model) with [`POST /v1/workspaces/:id/providers`](/docs/api/custom-providers): a `base_url`, an auth template, the model ids it serves, manual pricing, and optional extra headers. Custom-provider egress is guarded against SSRF: HTTPS-only (loopback HTTP only with an explicit dev flag), private/link-local/metadata IP ranges rejected, re-validated on every redirect. See [self-hosted models](/docs/guides/self-hosted). **Declare `pricing` to route to it.** The gateway only routes to an offer whose cost it can compute. A custom provider registered without `pricing` is refused before dispatch with the GW-70 `model_unpriced` error (HTTP 403), both for your own requests and for evaluation or replay jobs that would call it. Otherwise every call to it would be recorded as `$0`, where your spend caps, budgets and savings could not see it. A declared price of `0` (`{ "input": 0, "output": 0 }`) is accepted: that is a price you stated, and those requests record a stated `$0`. Add pricing later with `PATCH /v1/workspaces/:id/providers/:providerId`. ## Funding: choose BYOK to use your own keys A new workspace is funded by prepaid UR credits (`fundingMode: "ur_credits"`). UR credits pay platform keys only: they never pay your own BYOK keys or a custom provider. To route through your own keys or your own provider, choose BYOK funding once, as a workspace owner, admin or billing member: ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/$WORKSPACE_ID/billing/funding" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "mode": "byok", "allowPlatformFallback": false, "requestKey": "byok-choice-0001" }' ``` `requestKey` (16–128 characters of `A–Z a–z 0–9 _ -`) makes the call idempotent. The choice is audited and takes effect within a few seconds. `allowPlatformFallback: true` also permits platform keys (paid from UR credits) when your keys cannot serve a request. Check the current mode with [`GET /v1/workspaces/:id/billing/account`](/docs/api/billing). Until you choose BYOK, a request that only your own key or provider could serve is refused with HTTP 403 and the error code `permission`. The message names the provider and this route. The refusal is not retryable, and the request never reaches your provider. With BYOK funding your keys pay the provider. UR credits are not debited for those requests. ## Data-collection policy `provider.data_collection: "deny"` keeps only zero-data-retention (strict) offers. Per-provider data policy is surfaced in the dashboard. --- # Review and curate what the flywheel learns source: https://docs.ultrarouter.io/docs/guides/review-queue (https://docs.ultrarouter.io/docs/guides/review-queue.md) Our promise is **automated** learning: the flywheel turns your everyday usage into lessons without asking you to label anything. The review queue is the **human pipe** beside that promise — a place you **may** use to grade your own drafts and help curate what is learned, but never **have** to. Nothing here is a prerequisite for the flywheel to work. The queue lists the drafts for **one call type** — the model outputs captured from your imported history and your live passthrough traffic — and lets a human give each one a verdict. That verdict is a **declared correction**: your judgement on our output, never an oracle of correctness and never a quality score. ## Where it is In the dashboard under **Flywheel → Review queue** (`/flywheel/review`). The landing page lists each call type that has drafts to review with its progress; open one to reach that call type's queue at `/flywheel/review/`. ## What each item shows - **The draft** — your model's output at the exact version a reviewer saw, as a bounded excerpt. Captured-passthrough bodies are read through the one audited, tenant-isolated CEE accessor (every read is recorded in your audit log); import bodies are the intact content-addressed blobs the importer stored. The draft is the model's answer text whatever the provider shape — OpenAI chat completions, Anthropic Messages, OpenAI Responses or a reconstructed stream — not the raw envelope. When the answer is JSON it is shown **pretty-printed** (indented, syntax-tinted, with collapsible objects and arrays) and you can flip between **Pretty** and **Raw**; plain text is shown as-is. - **The source material** — the request that produced the draft, when it is present, with the same pretty/raw rendering. - **The AI reviewer's suggestion**, when one exists — its verdict, its reason codes and the repaired text a machine verifier proposed. If no suggestion exists, the item simply shows none; we never fabricate one. Bodies are shown only under **corrections consent**. With consent off you can still approve or reject, but drafts, edits and suggestions are neither shown nor stored — turn consent on from the Flywheel screen to edit and to accept suggestions. ## The four verbs Each item offers four actions, keyboard-complete with `a` / `e` / `s` / `r` on the active item: - **Approve as-is** (`a`) — records `correction:accepted_unchanged`. The draft was good; your acceptance is the lesson. - **Edit** (`e`) — opens an inline editor seeded with the **full** draft (loaded on demand, not the shortened excerpt), so you edit the whole body. If the draft is JSON your edit must stay valid JSON — you get a named inline error otherwise. Save to record `correction:edited` with a character-level diff (computed on the server against the full draft, never a value sent from your browser); your edited text is stored as the human-approved **final**. - **Accept the AI's suggestion** (`s`) — one click makes the verifier's repaired text your declared final, recorded `correction:edited` and marked **human-accepted** (the accepted receipt id rides along), so provenance stays honest: a human accepted a machine suggestion — it is never relabelled as machine-approved. Shown only when a suggestion exists. - **Reject with a reason** (`r`) — records `correction:rejected` with one **closed reason code** (off topic, inaccurate, tone, incomplete, formatting, policy, other). A rejection carries the code alone — never a free-text note in an outcome value. ## Leave a feedback note (optional) Any action can carry an optional free-text note — "what should change?", up to 2,000 characters. The note is **content**, so it is kept only under **corrections consent**: the text is stored in the encrypted blob store and linked to the draft's artifact and the correction you just recorded, while the outcome event itself still carries no text (at most a hash). With consent off the note box is disabled with a one-line reason, and nothing is saved. A stored note follows the same retention as your other correction bodies — it is swept with them and removed when you revoke consent. The flywheel may read your notes as evidence when it decides what to learn; a note is never a training label by itself. ## Fix with AI When you know what should change but don't want to type the whole revision yourself, write a feedback note and press **Fix with AI**. We ask **your own model** to revise the draft using your note (and your reject reason, if you picked one) and load the result into the editor for you to **accept, change, or discard**. If the draft is JSON, the revision keeps the same JSON shape. This is a real model call, so it is **your usage and your spend**: it runs through the same metered gateway path as any other request — a call type named `ur_review_assist`, a request id you can find in your logs, and your workspace spend cap applies. The cost of the revision is shown next to the editor. If your workspace is at its monthly cap, Fix with AI pauses with a clear message until the cap resets or you raise it. The AI's revision is **never saved on its own** — it only fills the editor. Nothing is recorded until you **Save as final**, which is a normal `edit` (a declared `correction:edited` with your final text). Fix with AI needs **corrections consent** on (we read the full draft to revise it); with consent off the button is disabled with a one-line reason. Every action writes through the **existing** declared outcome paths — the same `POST /v1/artifacts/:id/outcomes` and `POST /v1/artifacts/:id/final` an SDK or a reviewer webhook uses — never a second ledger. When a captured passthrough draft has no artifact yet, the sanctioned request-shortcut path records the correction and resolves the artifact; no new mechanism is invented. Every action also writes an audit event (ids and counts only — never a body). ## What it does not do - It never claims an edit, acceptance or rejection is an oracle of correctness. - It never trains a model by itself: a declared correction is a lesson the flywheel may learn from under your governance, through the same gates as every other outcome; the holdouts are still skipped before any teaching reader reads a body. - It is not a scoreboard and not required. Progress (n of N reviewed) is there if you want to work through a batch; leaving it at 0 changes nothing about the automated loop. ## See also - [Capture your first reviewer correction](/docs/guides/correction-start) — the webhook adapter that brings corrections in from a reviewer system you already run. - [Record outcomes](/docs/guides/outcomes) — the outcome API the review queue records through. --- # Python SDK source: https://docs.ultrarouter.io/docs/guides/sdk-python (https://docs.ultrarouter.io/docs/guides/sdk-python.md) The `ultrarouter` package mirrors the `openai` python v1 ergonomics — sync + async, built on `httpx`, typed with pydantic v2 (py ≥ 3.10). `ultrarouter` is not on PyPI. Clone the public `feat/learning-loop` branch for the learning preview (0.1.0), build its wheel, and install that file. The default `main` branch does not carry this preview. Exact current artifact names are in [`/setup-recipes.json`](/setup-recipes.json); `` below is the version of the package you build. ```bash git clone --branch feat/learning-loop --single-branch https://github.com/lucastonon/ultrarouter-sdk-python ../ultrarouter-sdk-python (cd ../ultrarouter-sdk-python && uv build) pip install ../ultrarouter-sdk-python/dist/ultrarouter--py3-none-any.whl # with the openai drop-in adapter (the extra pulls openai from PyPI): pip install "../ultrarouter-sdk-python/dist/ultrarouter--py3-none-any.whl[openai]" ``` ## Quickstart ```python from ultrarouter import UltraRouter ur = UltraRouter( api_key="ur_sk_test_...", # or set ULTRAROUTER_API_KEY / UR_API_KEY default_model="anthropic/claude-sonnet-4.6", defaults={"router": "off", "compression": "semantic_safe", "provider": {"sort": "price"}}, ) res = ur.chat.completions.create( model="openai/gpt-5-mini", # per-call override of default_model messages=[{"role": "user", "content": "hi"}], call_type="support-reply-draft", router="auto", compression={"mode": "auto", "no_compress_fields": ["messages.1.content"]}, ) print(res.choices[0].message.content, res.ur.cost_usd) ``` ## Async ```python import asyncio from ultrarouter import AsyncUltraRouter async def main(): async with AsyncUltraRouter(api_key="ur_sk_test_...") as ur: res = await ur.chat.completions.create( model="openai/gpt-5-mini", messages=[{"role": "user", "content": "hi"}], ) print(res.ur.cost_usd) asyncio.run(main()) ``` ## Extensions and drop-in ```python emb = ur.embeddings.create(model="openai/text-embedding-4-small", input=["a", "b"]) models = ur.models.list(capability="vision") cts = ur.call_types.list() ur.requests.feedback(res.ur.request_id, rating=1, tags=["great"]) stats = ur.generation.get(res.ur.request_id) client = ur.as_openai() # a configured openai.OpenAI (requires the [openai] extra) ``` Retries `429` / `5xx` / transport errors with jittered backoff honoring `Retry-After` (`max_retries`, default 2). Non-streaming POSTs carry a stable auto `Idempotency-Key`. Errors are `UltraRouterError` (`.code`, `.status`, `.request_id`, `.retryable`, `.provider_code`). > The SDK's source is the public repository > [lucastonon/ultrarouter-sdk-python](https://github.com/lucastonon/ultrarouter-sdk-python). > Until the package is published to PyPI, install the wheel built from that repository with > `uv build`, as shown at the top of this page. --- # TypeScript SDK source: https://docs.ultrarouter.io/docs/guides/sdk-typescript (https://docs.ultrarouter.io/docs/guides/sdk-typescript.md) `@ultrarouter/sdk` mirrors the `openai` v5 surface and adds the ultrarouter extensions. It is fetch-based (Node ≥ 20 / Bun / Deno / edge), ESM + CJS, with a core entry under 15 kB min+gz. `@ultrarouter/sdk` is not on the npm registry. Clone the public `feat/learning-loop` branch for the learning preview (0.1.0), build its tarball, and install that file. The default `main` branch does not carry this preview. Exact current artifact names are in [`/setup-recipes.json`](/setup-recipes.json); `` below is the version of the package you build. ```bash git clone --branch feat/learning-loop --single-branch https://github.com/lucastonon/ultrarouter-sdk-typescript ../ultrarouter-sdk-typescript (cd ../ultrarouter-sdk-typescript && npm ci && npm run build && npm pack) npm install ../ultrarouter-sdk-typescript/ultrarouter-sdk-.tgz ``` ## Quickstart ```ts import UltraRouter from "@ultrarouter/sdk"; const ur = new UltraRouter({ apiKey: process.env.UR_API_KEY, // or ULTRAROUTER_API_KEY defaultModel: "anthropic/claude-sonnet-4.6", defaults: { router: "off", compression: "semantic_safe", provider: { sort: "price" }, }, }); const res = await ur.chat.completions.create({ model: "openai/gpt-5-mini", // per-call override of defaultModel messages: [{ role: "user", content: "hi" }], call_type: "support-reply-draft", router: "auto", compression: { mode: "auto", no_compress_fields: ["messages.1.content"] }, models: ["deepseek/deepseek-v4-flash"], // fallbacks }); console.log(res.choices[0].message.content, res.ur.cost_usd); ``` Init defaults are overridable per call. Every `ur` extension is typed. ## Streaming ```ts const stream = await ur.chat.completions.create({ messages: [{ role: "user", content: "stream please" }], stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta.content ?? ""); if (chunk.ur) console.log("\n", chunk.ur.request_id, chunk.ur.cost_usd); } ``` ## The ur.* extensions ```ts const emb = await ur.embeddings.create({ model: "openai/text-embedding-4-small", input: ["a", "b"] }); const models = await ur.models.list({ capability: "vision" }); const cts = await ur.ur.callTypes.list(); await ur.ur.requests.feedback(res.ur.request_id, { rating: 1, tags: ["great"] }); const stats = await ur.ur.generation.get(res.ur.request_id); ``` ## Drop-in for `openai` `asOpenAI()` returns an OpenAI-client preset (base URL + key). `openai` is not a dependency of the SDK — pass the constructor to get a live instance: ```ts import OpenAI from "openai"; const client = ur.asOpenAI(OpenAI); // new OpenAI({ baseURL: "/v1", apiKey }) ``` ## Retries, timeouts, idempotency Retries `429` / `5xx` / transport errors with jittered backoff honoring `Retry-After` (`maxRetries`, default 2). A total `timeout` and per-call `AbortSignal` are honored. Non-streaming POSTs carry a stable auto `Idempotency-Key` so a retried mutation replays instead of double-executing. Errors are `UltraRouterError` (`.code`, `.status`, `.requestId`, `.retryable`, `.providerCode`). > The SDK's source is the public repository > [lucastonon/ultrarouter-sdk-typescript](https://github.com/lucastonon/ultrarouter-sdk-typescript). > Until the package is published to npm, install the tarball built from that repository, as > shown at the top of this page. ## CI quality gate — the `ur` bench CLI (EVL-95 / SDK-16) The `@ultrarouter/sdk` package ships an `ur` bin that gates a CI build on a Bench **baseline compare**: it captures bench-tagged traffic, freezes the suite, re-runs your _current_ prompt/pin/config as the candidate, and compares it against a pinned baseline under the interval-based decision contract — quality non-inferiority **and** cost-savings intervals must both clear their bars for a `pass`; a regression, a hard-scorer break, or an absolute per-task cost-ceiling breach is a `fail`; missing paired evidence is `inconclusive` (never silently green). ```bash ur bench run checkout-agent --capture "pnpm test:agent" \ --budget 3 --gate --baseline bench:main --json # exit 0 = pass · exit 2 = fail / inconclusive · fails closed on any API error ``` A published **GitHub Action** wraps the CLI (inputs: `suite`, `api-key`, `baseline`, `budget`, `capture`, `cost-ceiling`, `allow-inconclusive`, `pin-baseline`) — see `action.yml` and `docs/github-action.md` in [lucastonon/ultrarouter-sdk-typescript](https://github.com/lucastonon/ultrarouter-sdk-typescript) (`uses: lucastonon/ultrarouter-sdk-typescript@v0.0.1`). The routes it drives (`POST /v1/bench/runs/:id/baseline`, `GET /v1/bench/runs/:id/gate`) are in the API reference. --- # Self-hosted models source: https://docs.ultrarouter.io/docs/guides/self-hosted (https://docs.ultrarouter.io/docs/guides/self-hosted.md) If you run your own model behind an OpenAI-compatible server (vLLM, TGI, Ollama, LM Studio, …), register it as a **custom provider** and route to it like any other. ## 1. Expose an OpenAI-compatible endpoint For example, vLLM serves the OpenAI API: ```bash python -m vllm.entrypoints.openai.api_server \ --model meta-llama/Llama-3.3-70B-Instruct \ --served-model-name my-llama --port 8000 ``` That gives you `http://your-host:8000/v1/chat/completions`. ## 2. Register the provider ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/$WS_ID/providers" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "name": "My vLLM", "base_url": "https://your-host:8000/v1", "auth": { "header": "Authorization", "value_template": "Bearer {{secret}}" }, "model_ids": ["my-llama"], "pricing": { "input": 0, "output": 0 } }' ``` Then store the upstream token for it (envelope-encrypted, never returned): ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/$WS_ID/provider-keys" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "provider_id": "'"$PROVIDER_ID"'", "secret": "your-upstream-token" }' ``` `pricing` is in integer micro-USD per 1M tokens (`input`, `output`, optional `cached`). **Declare it — the gateway only routes to an offer whose cost it can compute.** A provider registered without `pricing` is refused before dispatch with the GW-70 `model_unpriced` error (HTTP 403), because its calls would otherwise be recorded as `$0` where spend caps, budgets and savings cannot see them. A self-hosted model you do not pay per token for declares `0`, as above. That is a price you stated, and those requests record a stated `$0`. The base URL is SSRF-validated on save (and on every update). The native model id is sent upstream verbatim. Your workspace pays this endpoint with your own token, so choose BYOK funding once (a new workspace starts on prepaid UR credits, which never pay a provider you run): ```bash curl -sS -X POST "$UR_BASE_URL/v1/workspaces/$WS_ID/billing/funding" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "mode": "byok", "allowPlatformFallback": false, "requestKey": "byok-choice-0001" }' ``` Without it the request is refused with HTTP 403 `permission`, naming this route. See [Providers & BYOK](/docs/guides/providers-byok#funding-choose-byok-to-use-your-own-keys). Then route to it: ```json { "model": "my-llama", "messages": [{ "role": "user", "content": "hi" }] } ``` ## 3. Declare what the endpoint supports Add a `capabilities` object to tell the router what your endpoint can do. The known flags are `tools`, `json_schema`, `vision`, `streaming`, and `embeddings`. **Omit it and every flag defaults to `true`** (the assumed OpenAI-compatible set) — existing providers are unchanged. ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/$WS_ID/providers/$PROVIDER_ID" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "capabilities": { "json_schema": false } }' ``` A flag set to `false` makes the router **refuse a request that needs it before dispatch**, with the named `capability_unsupported` error — never a silent downgrade of your `response_format` and never a wasted upstream call. For example, an endpoint that does not implement structured outputs should declare `"json_schema": false`, so a `response_format: { "type": "json_schema" }` request is rejected up front instead of failing at the provider. The same flags appear on `GET /v1/models`, so discovery agrees with dispatch. Change them any time from the API or the dashboard `/providers` form. ## Security notes - **HTTPS only.** Plain `http://` + loopback is allowed only when the gateway runs with `UR_ALLOW_INSECURE_CUSTOM_PROVIDERS=true` (dev/e2e only). - Private, link-local, and cloud-metadata IP ranges are rejected; redirects are re-validated (max 2). - The upstream secret is envelope-encrypted and never returned by the API. The dashboard `/providers` screen offers a guided form with a live "test connection" and a real invoke. --- # Self-learning agents source: https://docs.ultrarouter.io/docs/guides/self-learning-agents (https://docs.ultrarouter.io/docs/guides/self-learning-agents.md) Episode memory turns the traffic your agent already sends into a private, growing memory of what worked: when your app reports that a task **passed**, the gateway keeps that episode, and on later tasks it serves your agent the most similar past episodes — with the actions that solved them — as reference data on every step. You change nothing in the agent itself: the same OpenAI-compatible calls, plus **one outcome report per finished episode**. Episode memory is a new, **pre-evidence** capability. Its first held-out confirmation did **not** confirm that memory built from more verified episodes beats an earlier, smaller memory, and a second study is under way. This page therefore makes **no** claim that it improves accuracy, speed or cost — it describes the mechanism only. It is **retrieval** of your agent's own verified episodes; ultrarouter builds no model from them. We turn it on per call type while it is pre-evidence — ask us to enable it for a call type, and you can turn it off yourself at any time (below). Learned **lessons** (a playbook distilled from your verified episodes) can also be turned on self-serve, with a promotion policy that decides when a newly learned version is served — see [Learning controls](/docs/guides/learning-controls). That self-serve path requires the workspace **Allow learning** opt-in, which grants both corrections and knowledge consent. The owner uses the dashboard Learning page or Learn onboarding flow; the session-only equivalent is `PUT /v1/workspaces/{id}/self-learning` with `{"enabled":true}`. ## Prerequisites - **The stack running.** Locally, `pnpm dev:e2e` boots the gateway on `http://localhost:8080`. These endpoints are part of the learning preview, which is not on the invite-only hosted gateway (`https://api.ultrarouter.io`) yet — run the local stack or point at your own deployment. - **Corrections consent on.** The task and the agent's steps are your workspace's content, so episode memory only records and serves them when the workspace owner has turned on `corrections_consent` (the dashboard Flywheel page, Corrections consent). Revoking it hard-deletes the stored episodes. - **A call type with episode memory enabled.** Ask us to enable it for the call type whose agent you want to improve (it is staff-granted while pre-evidence). ```bash export UR_BASE_URL="http://localhost:8080" export UR_API_KEY="ur_sk_live_…" # ur_sk_live_* or ur_sk_test_* ``` ## Step 1 — point the agent at the gateway, with a key that carries a default call type Change only the base URL and the key. So your requests stay plain OpenAI — no ultrarouter field, no header, no SDK change — **mint a key with a default call type**: the gateway then attaches that call type to every request on the key. Set it when you create the key (`default_call_type`) or later: ```bash curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/$UR_WORKSPACE_ID/api-keys/$UR_KEY_ID" \ -H "Authorization: Bearer $UR_API_KEY" -H "Content-Type: application/json" \ -d '{ "default_call_type": "my-agent" }' # a slug or id of a call type in this workspace ``` ## Step 2 — keep your agent exactly as it is Each agent step is one chat-completions request carrying the conversation so far. The gateway groups all the steps of one task into a single **episode** using the dispatch cluster key it already computes, so you send **no** episode id and **no** header. Every response carries `x-ur-request-id`; keep the one from the episode's **last** step — that is where you report the outcome. ```ts import OpenAI from "openai"; // Only the base URL and key change — the rest is the stock OpenAI SDK. const client = new OpenAI({ apiKey: process.env.UR_API_KEY, baseURL: `${process.env.UR_BASE_URL}/v1`, }); // Your agent loop, unchanged: one request per step, each carrying the conversation so far. // `.withResponse()` exposes the raw response so you can read the request id header. const { data: completion, response } = await client.chat.completions .create({ model: "openai/gpt-5-mini", messages }) .withResponse(); const lastRequestId = response.headers.get("x-ur-request-id"); // …append completion.choices[0].message, run the tool, step again until the task is done, // updating lastRequestId each step… ``` ```python import os from openai import OpenAI # Only the base URL and key change — the rest is the stock OpenAI SDK. client = OpenAI(api_key=os.environ["UR_API_KEY"], base_url=f"{os.environ['UR_BASE_URL']}/v1") # `with_raw_response` exposes the headers; `.parse()` gives the usual completion object. raw = client.chat.completions.with_raw_response.create( model="openai/gpt-5-mini", messages=messages) last_request_id = raw.headers.get("x-ur-request-id") completion = raw.parse() # …append the assistant message, run the tool, step again until the task is done, # updating last_request_id each step… ``` ## Step 3 — report one outcome per finished episode After your own check of the finished task, POST **one** outcome on the episode's **last** request id. `channel: "computable"` with `kind: "oracle_pass"` or `"oracle_fail"` is a whole-episode teaching signal — an oracle verdict, not a thumbs-up. Only a `pass` becomes memory. ```ts // `passed` is your own oracle: a test suite, an exact-match check, a resolved ticket. await fetch(`${process.env.UR_BASE_URL}/v1/requests/${lastRequestId}/outcomes`, { method: "POST", headers: { Authorization: `Bearer ${process.env.UR_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ channel: "computable", kind: passed ? "oracle_pass" : "oracle_fail", }), }); ``` ```python import requests # `passed` is your own oracle: a test suite, an exact-match check, a resolved ticket. requests.post( f"{os.environ['UR_BASE_URL']}/v1/requests/{last_request_id}/outcomes", headers={"Authorization": f"Bearer {os.environ['UR_API_KEY']}"}, json={"channel": "computable", "kind": "oracle_pass" if passed else "oracle_fail"}, ) ``` Report it **the moment your check has run** — you do not wait for anything. The gateway records each step (and the episode it belongs to) just after answering it, so a report can arrive first; the answer is still `202`, with a durable `receipt`. `status: "pending"` only means the step has not been recorded yet — the outcome is applied when it is, and joins its episode as soon as the episode lands (`receipt.episode.status` becomes `associated`). Read the receipt with `GET /v1/outcome-receipts/{id}` if you want to watch it settle; send a `source_event_id` so a retry after a lost response returns the same receipt instead of a second outcome. The full receipt contract is in [Record outcomes](/docs/guides/outcomes). That is the whole integration: base URL + a key with a default call type, your agent unchanged, and one outcome per episode. ### Several tasks at once with the same opening Without a header, the gateway groups steps into episodes by their **opening** — the system prompt plus the first user message. Two tasks that run **at the same time** with identical openings are therefore one episode to it, and the first verdict reported wins for both. If your agent runs such tasks concurrently (a batch of identical prompts over different data, say), give each task its own episode handle — the same `X-UR-Artifact` header (or body `ur.artifact`) on every step of that task: ```ts await client.chat.completions.create( { model: "openai/gpt-5-mini", messages }, { headers: { "X-UR-Artifact": `task-${taskId}` } }, ); ``` Each handle is its own episode with its own verdict, however many share an opening. ## What gets learned — and what never does **Learned.** Only episodes your app **reports as passed**. A passing episode is kept as its task plus the actions that did not fail, and later episodes of the same call type are served the most similar ones (by task text) as reference data after the system messages — the same precedents on every step of a given episode. **Never.** - **Only your reported passes teach.** A proxy signal, a behavioral click, or the gateway's own mechanical signals never verify an episode — only an oracle, a resolution or a correction outcome you report does. - **The holdout is never touched.** A standing fraction of traffic (at least 5%) is never served memory and never recorded, so a fair comparison always exists. - **Only under consent.** With `corrections_consent` off, nothing is recorded or served; revoking it hard-deletes the workspace's stored episodes. - **Identifiers are redacted.** Every served precedent passes through the core PII redactor, so emails, phone numbers, names and the other categories are replaced with typed placeholders before another episode ever sees them. - **Per-workspace, never shared.** Your verified episodes are served only within your own workspace — never across workspaces, and never pooled into a shared or cross-workspace model. Episode memory is retrieval of your own history, not training. ## See it — and turn it off Read a call type's episode-memory status (the setting, the lab authorisation, and the verified / failed / unverified episode counts with the latest verification). These routes take the `ct_…` ID, not the slug. Resolve the ID for the `my-agent` call type used above: ```bash UR_CALL_TYPE_ID="$(curl -fsS "$UR_BASE_URL/v1/call-types" \ -H "Authorization: Bearer $UR_API_KEY" \ | jq -er '.data[] | select(.slug == "my-agent") | .id')" export UR_CALL_TYPE_ID curl -sS "$UR_BASE_URL/v1/call-types/${UR_CALL_TYPE_ID:?set the call type ID}/episode-memory" \ -H "Authorization: Bearer $UR_API_KEY" ``` The same figures and a small learning trend (per week, or per day while history is short) appear on the call type's **Agent memory** panel in the dashboard. Turn it off at any time — the kill switch is never staff-gated and takes effect within one config window: ```bash curl -sS -X POST "$UR_BASE_URL/v1/call-types/${UR_CALL_TYPE_ID:?set the call type ID}/episode-memory/disable" \ -H "Authorization: Bearer $UR_API_KEY" ``` ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | The status shows `enabled: false` | Episode memory is not on for the call type. It is staff-enabled while pre-evidence — ask us to turn it on. | | `verified_pass` stays 0 after reporting outcomes | Report the outcome on the episode's **last** request id (`x-ur-request-id` from the final step), and only `oracle_pass` becomes memory. A held-out episode is never recorded. | | Nothing is recorded at all | Turn on `corrections_consent` (the dashboard Flywheel page, Corrections consent); without it episode memory records and serves nothing. | | The outcome POST answers `status: "pending"` | Normal when you report right after the response: the step has not been recorded yet. The receipt is applied on its own — nothing to retry. | | The receipt `expired` with `request_not_found` | No step with that id was recorded in your workspace within 15 minutes — use the `x-ur-request-id` from a real step made with a key of this workspace. | | `404` on the outcome POST | The id is a gateway-minted `req_…` from more than 15 minutes ago with no recorded step — use the `x-ur-request-id` header from a real, recent step on this key. | | Two concurrent tasks share one verdict | Their openings are identical and they carry no episode handle, so they are one episode. Send a distinct `X-UR-Artifact` per task. | --- # The training-ready export source: https://docs.ultrarouter.io/docs/guides/training-export (https://docs.ultrarouter.io/docs/guides/training-export.md) The Flywheel's last stage turns your consented outcome ledger into a **training-ready dataset**: same-prompt supervised (SFT) and preference examples with pinned versions, an executable grader, and immutable content-addressed splits. The export is **yours**, and whether ultrarouter trains a model from your content is a **separate, governed decision** described below. This page explains what the export contains, why some examples are labelled `not_trainable`, the consent that has to be on before a single example is produced, and how any training that happens is governed. The export **converter and its guarantees are built** — the conversion, the trainability classifier, the immutable splits, the readiness report and the consent-revocation tombstones are all in code and covered by tests. What is a **later surface** is the packaged download from the dashboard; where this guide reaches that step it says what you **will** see, never a fabricated button. The consent below is live today. ## How training is governed **The no-training promise was reversed on 2026-09-20.** The contract now is exact: > We process authorized workspace content to train and evaluate models exclusively for your > workspace. We do not use it to train shared models or another customer's models. Approved > subprocessors, retention, disabling and deletion terms are disclosed. Training is allowed **only when every one of six conjuncts holds**, each failing _closed_ on missing evidence: ``` train_allowed = platform_enabled AND workspace_enabled AND contract_authorized AND source_eligible AND retention_valid AND provider_approved ``` - **platform_enabled / workspace_enabled** — a `superadmin`-only platform switch (default on) and a customer-visible per-workspace switch (default on for new workspaces under a training agreement accepted at signup). A superadmin may turn training off globally but can **never** override a customer's OFF. - **contract_authorized** — a versioned training agreement accepted for the workspace, plus the applicable processing authority for the people in the data (recorded per jurisdiction, never assumed). Content captured under the previous no-training promise needs **source-linked renewed authorization** before it may train. - **source_eligible** — workspace ownership, current source consents, validated train-eligible labels, and **both** holdouts excluded. - **retention_valid** — checked for every contributing source. - **provider_approved** — the operation, environment, region, subprocessors, no-reuse terms and regulated scope. A BAA alone is not sufficient; regulated / PHI workspaces stay **off** until an approved scope and contract chain exists. No lever and no trainer ever reads holdout traffic. Training authorization is **separate** from `training_export_consent`: enabling the export never authorizes training, and neither permission silently grants the other. Raw items never cross a workspace. **What is built, and what is not.** The two switches and the `train_allowed` enforcement **are built** (row TRN0b): the `superadmin`-only platform switch (default on) lives in the admin console, the per-workspace switch (default on) is on your **Flywheel** screen, and `train_allowed` is one pure function enforced at every gate. Turning the workspace switch off — or withdrawing consent — **invalidates the authorization epoch**: serving of that workspace's adapters stops within one 5 s window, active jobs are cancelled, and a stale provider callback can never restore an artifact (not even after training is re-enabled). What is **not** built is a **trainer**: no training run of any kind executes yet. Until a governed trainer lands (row TRN1), **no customer-data training run happens**, and the grep-level guarantee (`TST-FLW-TRAINING-GOVERNED`) holds that no production code path launches an **ungated** training job. The export is a dataset a **customer** can also train on themselves; your data stays tenant-isolated. ## What the export contains The converter (`@ultrarouter/core#buildTrainingExport`) produces: - **SFT examples** — a same-prompt completion, model-attributed, with the exact prompt/ config version and the recipe version the reference was derived under pinned to each row. - **Preference examples** — chosen/rejected pairs from your corrections. - **An executable grader spec** — reference values plus the closed FLW-24 predicate (`equals`, `within_tolerance`, `ge`, `le`, `resolved_by_source`) a consumer runs against a candidate model's output. The grader is reused for the test split. - **Immutable, content-addressed splits** — every prompt's split is a deterministic bucket of `(dataset salt, prompt hash)`: `train [0,80)`, `validation [80,90)`, `test [90,100)`. Re-running over the same inputs reproduces a **byte-identical** bundle; a changed input yields a new hash and a new version, never a mutated one. - **A readiness report** — counts, coverage and **limitations**. It states what the dataset can and cannot support; it never claims that learning or improvement occurred. ## What `not_trainable` means `not_trainable` is a **first-class label, never a silent drop.** Every candidate becomes an exported example; one that cannot honestly be trained on is exported **carrying its reason**, so a consumer can *count* what it cannot use rather than wonder why rows went missing. A `not_trainable` example carries **no training target** — the SFT completion or preference pair is simply absent. The reason vocabulary is closed, and it fails **closed** (an undeterminable state maps to the most conservative reason, never to "trainable"): | Reason | Why it cannot teach | | --- | --- | | `proxy_only` | A `proxy` channel row — proxies rank, they never teach. | | `rank_only` | A `behavioral` (or otherwise rank-carrying) row — a ranking signal, not a label. | | `audit_only` | `learning_use` is `audit` (infra/unclassified tool error, or an unqualified inferred rule). | | `holdout_arm` | The artifact's exposure arm is the sacred standing / lever holdout — the holdout never teaches. | | `replay_arm` | The FLW-75 `replay` hindcast arm — it ranks, never teaches. | | `mixed_arm` | A `mixed` artifact — its requests disagree, so it is ineligible for a target. | | `unqualified_extractor` | The teaching label came from an unqualified extractor — never a training label. | | `missing_consent` | Consent for the source was withdrawn. | | `no_reference` | No accepted/corrected reference output exists to learn from. | The rule underneath the table is the same law that runs through the whole Flywheel: **proxies rank; they never teach.** A training target can only be minted through the by-construction teaching gate — a `proxy` or `behavioral` row is *structurally incapable* of producing one, so the label and the target can never disagree. The export teaches only from the arms the rest of the system teaches from: `treated`, `pre_lever` and `imported`; the `holdout`, `lever_holdout`, `replay` and `mixed` arms are absent by construction. ## The consent that gates it The export is **owner-gated and off by default.** Two consents matter: - **`training_export_consent`** — allows the training-ready export to be produced at all. - **`corrections_consent`** — required for the stored final bodies the SFT targets are drawn from. Both are owner-only, audited, and bump the config version. Set them on the **Flywheel** screen, or over the API (a signed-in owner session, not a raw key): ```bash # Session-authenticated, owner-only. Enables the export and pins corrections retention. curl -sS -X PATCH "$UR_BASE_URL/v1/workspaces/ws_…/flywheel-consents" \ -H "Cookie: better-auth.session_token=$UR_SESSION" \ -H "Content-Type: application/json" \ -d '{ "training_export_consent": true, "corrections_consent": true }' ``` **Revocation reaches the export.** The bundle carries its `consentLineage` — the consents its sources derive from — so revoking a consent **tombstones** every export whose lineage crosses it. Revocation stops new derivations and marks existing exports; it never rewrites what was already produced. Neither consent defaults on, and both are revocable. ## What you will see next The converter, the trainability classifier, the split rule, the readiness report and the revocation tombstones are built and tested. The **packaged retrieval** — a downloadable dataset from the Flywheel screen, with its readiness report and `not_trainable` breakdown — is published as that surface ships. Until then, the substance is real: the consent gate is live, the conversion is deterministic and content-addressed, and the export/training separation holds today — enabling the export never authorizes training. ## What this proves — and what it does not **Proves.** Your consented outcome ledger can become a portable, versioned, content-addressed training dataset **you** own — with every untrainable example labelled and counted, teaching confined to the non-holdout arms by construction, and revocation reaching existing exports. No production code path launches an ungated training job (`TST-FLW-TRAINING-GOVERNED`). **Does not prove.** An export is a dataset, not a result. It does **not** demonstrate that training on it improves anything — proving that is a separate, governed step (yours to run, or ours per-workspace once the switches ship). The readiness report states counts and limitations, never a lift. ## Next steps - [The Flywheel](/docs/concepts/flywheel) — where the outcomes the export draws from are captured. - [Holdout and lift](/docs/concepts/holdout-and-lift) — why the holdout and `replay` arms are excluded from every training target. - [Record outcomes](/docs/guides/outcomes) — the capture that fills the ledger the export reads. --- # From OpenAI direct source: https://docs.ultrarouter.io/docs/migrate/from-openai (https://docs.ultrarouter.io/docs/migrate/from-openai.md) The official `openai` client runs unmodified against ultrarouter — change the base URL and key only. ```diff import OpenAI from "openai"; const client = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, + baseURL: "http://localhost:8080/v1", // or your gateway URL + apiKey: process.env.UR_API_KEY, // ur_sk_live_… / ur_sk_test_… }); ``` `chat.completions.create`, `embeddings.create`, and `models.list` all work with the same request/response shapes (streaming included). ## Field-by-field mapping | OpenAI | ultrarouter | Notes | | --- | --- | --- | | `model: "gpt-4o"` | `model: "openai/gpt-5.2"` | Prefix the model with its provider. | | `messages`, `tools`, `response_format`, `stream` | same | Unchanged request fields. | | `Authorization: Bearer sk-…` | `Authorization: Bearer ur_sk_…` | ultrarouter key. | | (n/a) | `model: "anthropic/…"`, `"google/…"`, … | Reach 13 other providers with the same client. | | (n/a) | `call_type`, `router`, `compression`, `models`, `provider` | Optional ultrarouter extensions. | | `usage` | `usage` + `ur` block | Extra `ur` metadata (id, provider served, cost, routing). | ## Notable differences - **Model ids are provider-prefixed** (`openai/gpt-5-mini`, not `gpt-4o-mini`) so you can mix providers with one client. - **Extensions are additive** — `call_type`, `router`, `compression`, `models`, `provider`, and the `ur` request options are ignored by a plain OpenAI client but understood by the gateway. - Everything is achievable with **raw REST** too — see the [API reference](/docs/api). --- # From OpenRouter source: https://docs.ultrarouter.io/docs/migrate/from-openrouter (https://docs.ultrarouter.io/docs/migrate/from-openrouter.md) ultrarouter is OpenRouter-shaped. In most cases you change the **base URL** and the **key** and everything works — provider-prefixed model ids, the `models` fallback array, provider preferences, and per-request stats all carry over. ```diff - baseURL: "https://openrouter.ai/api/v1" + baseURL: "http://localhost:8080/v1" // or your gateway URL - apiKey: "sk-or-v1-…" + apiKey: "ur_sk_live_…" ``` ## Field-by-field mapping | OpenRouter | ultrarouter | Notes | | --- | --- | --- | | `model: "openai/gpt-4o"` | `model: "openai/gpt-5.2"` | Provider-prefixed ids, same shape. | | `models: [...]` | `models: [...]` | Ordered fallbacks — identical. | | `provider: { order, ignore, quantizations, data_collection, allow_fallbacks }` | same | Same provider-preferences object. | | `:nitro` / `:floor` suffixes | `:nitro` / `:floor` | Throughput / price sort — supported. | | `GET /api/v1/generation?id=` | `GET /v1/generation?id=` | Post-hoc stats parity. | | `GET /api/v1/models` | `GET /v1/models` | Catalog with `?provider=` / `?capability=`. | | `GET /api/v1/key` | `GET /v1/key` | Key introspection. | | HTTP-Referer / X-Title headers | (not required) | Omit; ultrarouter uses call types instead. | ## Output cap & reasoning The modern OpenAI fields are carried through to the provider — never silently dropped. - **`max_completion_tokens`** (the modern output-token budget, covering visible **and** reasoning tokens) is honoured everywhere. If you send **both** `max_completion_tokens` and `max_tokens`, **`max_completion_tokens` wins** — it is the field OpenAI deprecated `max_tokens` in favour of, and the metered cost ceiling uses the same value. Each provider receives the cap in the field it accepts (see the table below). - **`reasoning_effort`** (`none` | `minimal` | `low` | `medium` | `high`) **and** the OpenRouter-native **`reasoning`** object (`{ effort, max_tokens, exclude }`) are both accepted. When both `reasoning_effort` and `reasoning.effort` are sent, the structured `reasoning` object wins. `none` disables reasoning and is always honoured (a no-op — never refused). ultrarouter follows the OpenRouter [reasoning-tokens reference](https://openrouter.ai/docs/use-cases/reasoning-tokens): effort-based providers receive `reasoning_effort`; budget-based providers receive a thinking-token **budget** derived from the effort as a share of the output cap (`high` ≈ 80 %, `medium` ≈ 50 %, `low` ≈ 20 %, `minimal` ≈ 10 %), or the direct `reasoning.max_tokens` (Anthropic-style, minimum 1024). Where a provider has no reasoning knob the request is refused with a named `capability_unsupported` error (`400`) before dispatch — never silently ignored. In `auto`/`candidates` routing a candidate that cannot honour the effort is simply skipped, so an offer that can is chosen. | Provider family | `max_completion_tokens` → | reasoning effort → | | --- | --- | --- | | OpenAI (reasoning models: `gpt-5+`, `o`-series) | `max_completion_tokens` | `reasoning_effort` (native) | | OpenAI (`gpt-4.x` / older) + every OpenAI-compatible provider (DeepSeek, Groq, xAI, Together, Fireworks, DeepInfra, Novita, Cerebras, Mistral, Qwen, Kimi) + custom `base_url` providers | `max_tokens` | `reasoning_effort` forwarded (the upstream honours or rejects it) | | Anthropic | `max_tokens` | **mapped** → `thinking: { type:"enabled", budget_tokens }` (extended thinking requires `temperature` 1 / no `top_p` — a conflict is refused named) | | Google Gemini | `maxOutputTokens` | **mapped** → `thinkingConfig.thinkingBudget` (`none` → 0) | | Amazon Bedrock — Anthropic (Claude) models | `maxTokens` | **mapped** → Converse `additionalModelRequestFields.thinking` | | Amazon Bedrock — non-Anthropic models | `maxTokens` | **refused** — `capability_unsupported` (400) | | Cohere | `max_tokens` | **refused** — `capability_unsupported` (400) | `reasoning.exclude: true` (drop reasoning tokens from the response) is not supported and is refused named. ## What you gain - **Call types** — attach `call_type` to tune routing + compression per workload (see [call types](/docs/concepts/call-types)). - **Compression** — off by default; opt-in `semantic_safe` / `adaptive` per call type. - **`ur` block** — every response carries the request id, model + provider served, the routing trail, compression applied, and cost. - **Savings accounting** — an auditable [ledger](/docs/concepts/savings). Provider preferences, fallbacks, and the stats endpoints keep the same semantics, so an OpenRouter integration typically migrates by swapping the constructor. --- # Pricing source: https://docs.ultrarouter.io/docs/pricing (https://docs.ultrarouter.io/docs/pricing.md) The new prepaid plan separates model usage, payment fees, and analysis. It is available only where checkout is enabled. Existing accounts keep their recorded billing terms until they explicitly switch plans. ## Starter Basic cost analysis and recorded model comparisons have no subscription fee. Inference still requires prepaid credits or your own provider keys. Evaluations require a separate spending ceiling and available funds; there are no unlimited free model tests. ## Credits: 5.5% when you purchase Purchase at least **$5 of credits**. The **5.5% fee** is added to the credit value, rounded to the nearest cent, and includes payment processing. For example, $100 of credits costs $105.50; $5 of credits costs $5.28. Credits pay for measured provider usage. The new prepaid plan does not add a second monthly percentage fee. A request with uncertain usage keeps its reserved amount pending reconciliation, rather than displaying an invented zero cost. ## Pro: $49 per month Pro includes deeper analysis and a **$5 noncash evaluation allowance** for the active billing period. The allowance does not roll over or become withdrawable credits. Further evaluations use available credits within your spending ceiling. The planned earned-Pro benefit uses the prior closed UTC month's settled contribution after processing, reversals, and subsidized evaluation costs. At least **$98 of contribution** qualifies for the next month. An unresolved payment or pending subscription adjustment is shown as pending, not as a confirmed fee waiver. This is not a fixed monthly usage threshold. ## Bring your own keys BYOK is optional. You pay your provider directly; the prepaid plan adds no percentage charge to those tokens. Falling back to ultrarouter-funded inference requires explicit permission and available credits. Pro and evaluation limits remain separate from your provider's bill. ## Optimization requires evidence and permission Manual mode presents comparisons for your review. Automatic routing requires explicit opt-in, qualifying independent evidence, and a supported application integration. Capturing call content and using knowledge each have separate permissions. A development comparison or subscription does not authorize a model change, and no plan promises that every workflow can be optimized. --- # Quickstart source: https://docs.ultrarouter.io/docs/quickstart (https://docs.ultrarouter.io/docs/quickstart.md) ultrarouter speaks the OpenAI API. If you have code that calls OpenAI or OpenRouter, you change two things — the **base URL** and the **API key** — and everything else keeps working. There are **two coequal doors**, both first-class: point your existing OpenAI or OpenRouter SDK at the gateway base URL (shown below), **or** install the [`@ultrarouter/sdk`](/docs/guides/sdk-typescript) (TypeScript) / [`ultrarouter`](/docs/guides/sdk-python) (Python) client for the same calls plus the `ur.*` extensions. Pick whichever fits your codebase — neither is a second-class path. ## 1. Get a key and base URL An ultrarouter key looks like `ur_sk_live_…` (or `ur_sk_test_…`). You send it as a bearer token: ``` Authorization: Bearer $UR_API_KEY ``` The base URL is your gateway. The hosted gateway is `https://api.ultrarouter.io`, invite-only for now: invited accounts sign in at `https://app.ultrarouter.io`, add their provider keys and create a key there. Running the stack locally (`pnpm dev:e2e`) the base URL is `http://localhost:8080`; you can also point at your own deployment. ```bash export UR_BASE_URL="https://api.ultrarouter.io" # or http://localhost:8080 for the local stack export UR_API_KEY="ur_sk_live_…" ``` ## 2. Make a chat completion ```bash curl -sS "$UR_BASE_URL/v1/chat/completions" \ -H "Authorization: Bearer $UR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5-mini", "messages": [{ "role": "user", "content": "Say hello in one word." }] }' ``` ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: `${process.env.UR_BASE_URL}/v1`, apiKey: process.env.UR_API_KEY, }); const res = await client.chat.completions.create({ model: "openai/gpt-5-mini", messages: [{ role: "user", content: "Say hello in one word." }], }); console.log(res.choices[0].message.content); ``` ```python from openai import OpenAI client = OpenAI(base_url=f"{os.environ['UR_BASE_URL']}/v1", api_key=os.environ["UR_API_KEY"]) res = client.chat.completions.create( model="openai/gpt-5-mini", messages=[{"role": "user", "content": "Say hello in one word."}], ) print(res.choices[0].message.content) ``` Every response carries a `ur` block with the request id, the model + provider actually served, the routing trail, compression applied, and the cost in USD. ## 3. Verify your setup These checks list the model catalog with your key — a keyless local stack still serves the catalog, so they prove auth + the base URL end to end. Each is executed against the live gateway in CI (DOC-31), so a broken example fails the build. ```bash exec curl -sS --fail "$UR_BASE_URL/v1/models" \ -H "Authorization: Bearer $UR_API_KEY" > /dev/null echo "ok: models endpoint reachable" ``` ```ts exec const res = await fetch(`${process.env.UR_BASE_URL}/v1/models`, { headers: { Authorization: `Bearer ${process.env.UR_API_KEY}` }, }); if (!res.ok) { console.error("HTTP", res.status); process.exit(1); } const data = await res.json(); console.log(`ok: ${data.data.length} models`); ``` ```python exec import json, os, urllib.request req = urllib.request.Request( f"{os.environ['UR_BASE_URL']}/v1/models", headers={"Authorization": f"Bearer {os.environ['UR_API_KEY']}"}, ) with urllib.request.urlopen(req) as r: print(f"ok: {len(json.load(r)['data'])} models") ``` Introspect the key itself (scopes, caps, usage this period): ```bash exec curl -sS --fail "$UR_BASE_URL/v1/key" \ -H "Authorization: Bearer $UR_API_KEY" > /dev/null echo "ok: key introspection reachable" ``` ## Next steps - Use the [TypeScript](/docs/guides/sdk-typescript) or [Python](/docs/guides/sdk-python) SDK for retries, streaming helpers, and the `ur.*` extensions. - Learn [call types](/docs/concepts/call-types) to tune routing and compression per workload. - Bring your own keys or a [custom provider](/docs/guides/providers-byok). --- # Security & data handling source: https://docs.ultrarouter.io/docs/security (https://docs.ultrarouter.io/docs/security.md) ## Tenancy isolation Every tenant-owned table enforces Postgres row-level security (default-deny, keyed on the workspace), and every management route enforces authorization at three layers: route middleware (RBAC), the service function (re-derived RBAC), and RLS. Owner/admin actions require 2FA. Once an account has an authenticator (TOTP) enrolled, signing in to the dashboard is two steps: the password, then the 6-digit code from the authenticator app (optionally trusting the device for 30 days). ## Secrets - **API keys** — only a SHA-256 hash and a display prefix are stored; the raw `ur_sk_*` secret is returned exactly once on create/roll and never logged. - **BYOK & custom-provider secrets** — envelope-encrypted (a per-workspace data key wrapped by a versioned master key), decrypted only in memory at call time, zeroized after use, and never returned by the API (last-4 display only). - Secrets never appear in logs or audit diffs. ## Request/response payloads The gateway **never logs request or response bodies** — logs carry ids, lengths, and hashes only. Whether bodies are stored is controlled per workspace by the retention mode: - **`none`** — nothing stored. - **`metadata`** (default) — request metadata only. - **`full`** — bodies stored (gzip, 32 KB-capped) so you can reveal them in the logs explorer. Revealing a payload is retention-gated and audited. A configurable TTL (1–90 days, default 30) plus a retention sweeper hard-delete stored payloads past their window, with a per-workspace audit summary. ## Audit trail Mutations write to a hash-chained, append-only audit log; a nightly worker re-verifies the chain. The dashboard audit viewer shows a per-row tamper-evidence badge. ## Egress (SSRF) Custom-provider egress is guarded: HTTPS-only (loopback HTTP only behind an explicit dev flag), DNS-resolved and rejected for private/link-local/metadata ranges, and re-validated on every redirect (max 2). ## Webhooks Webhook secrets are envelope-encrypted; deliveries are HMAC-signed (`X-UR-Signature: sha256=…` over `.`) and the target URL is SSRF-validated on create and edit. ## Region & hosting Single-region v1; the region is disclosed read-only in settings. The hosted gateway (`https://api.ultrarouter.io`) is invite-only for now: invited accounts sign in at `https://app.ultrarouter.io` and bring their own provider keys, which are envelope-encrypted at rest. There is no hosted MCP endpoint. You can also self-host or run the stack locally.