Flywheel
The outcome ledger — artifacts, typed outcome events, finals, pending claims.
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.
Preview — not on the hosted service
learning.enabled master switch), there is no hosted MCP endpoint, and whether learning improves a given agent is not yet demonstrated. See the evidence status.Register an artifact type / append an objective version
POST /v1/artifact-typesDeclare 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_…).
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)
GET /v1/artifactsKeyset-paginated artifact metadata (never OFFSET). Filter by archetype/key. Requires API-key scope: outcomes:write.
Auth: API key (Authorization: Bearer ur_sk_…).
curl -sS -X GET "$UR_BASE_URL/v1/artifacts" \
-H "Authorization: Bearer $UR_API_KEY"Create or upsert an artifact by key
POST /v1/artifactsCreate/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_…).
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)
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_…).
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
POST /v1/artifacts/{id}/outcomesRecord 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_…).
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)
POST /v1/artifacts/{id}/finalOutcome 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_…).
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
POST /v1/outcomes/batchRecord ≤ 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_…).
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
GET /v1/outcomes/pendingKeyset-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_…).
curl -sS -X GET "$UR_BASE_URL/v1/outcomes/pending" \
-H "Authorization: Bearer $UR_API_KEY"Record an outcome via the request shortcut
POST /v1/requests/{id}/outcomesRecord 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_…).
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
GET /v1/requests/{id}/outcome-receiptsEvery 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_…).
curl -sS -X GET "$UR_BASE_URL/v1/requests/req_0000000000000000/outcome-receipts" \
-H "Authorization: Bearer $UR_API_KEY"Read one outcome receipt
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_…).
curl -sS -X GET "$UR_BASE_URL/v1/outcome-receipts/req_0000000000000000" \
-H "Authorization: Bearer $UR_API_KEY"Update the workspace Flywheel consents
PATCH /v1/workspaces/{id}/flywheel-consentsOwner-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.
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
GET /v1/workspaces/{id}/self-learningThe 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.
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
PUT /v1/workspaces/{id}/self-learningThe 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.
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
GET /v1/workspaces/{id}/trainingThe 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.
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
PATCH /v1/workspaces/{id}/trainingFlip 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.
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
GET /v1/workspaces/{id}/site-briefThe 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.
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)
PUT /v1/workspaces/{id}/site-briefRequest a fetch of the customer’s public website/docs (hybrid onboarding’s first source). Give an explicit url, or omit it to INFER https://<signup-email-domain> (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.
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
POST /v1/flywheel/grantsThe 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_…).
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)
POST /v1/flywheel/review/assistThe 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.
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
GET /v1/flywheel/statusThe 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_…).
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
GET /v1/flywheel/moat-nextThe 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_…).
curl -sS -X GET "$UR_BASE_URL/v1/flywheel/moat-next" \
-H "Authorization: Bearer $UR_API_KEY"List the workspace bounded experiments
GET /v1/flywheel/experimentsLIST 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.
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
POST /v1/flywheel/experimentsDeclare 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.
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
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.
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
POST /v1/flywheel/experiments/{id}/admitRE-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.
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
POST /v1/flywheel/experiments/{id}/produceLAUNCH 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.
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)
POST /v1/flywheel/experiments/{id}/executionsENROL 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_…).
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)
POST /v1/flywheel/experiments/{id}/executions/{execution_id}/outcomeRECORD 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:<stage> (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:<gate> (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_…).
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
POST /v1/flywheel/experiments/{id}/screen/evaluateEVALUATE 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_…).
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)
GET /v1/flywheel/experiments/{id}/holdsLIST 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.
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)
POST /v1/flywheel/experiments/{id}/holds/reconcileRECONCILE 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.
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
POST /v1/flywheel/experiments/{id}/screen/judgementsRECORD 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.
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
POST /v1/flywheel/experiments/{id}/screen/acknowledgeACKNOWLEDGE 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.
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)
POST /v1/flywheel/experiments/{id}/test/manifestCOMMIT, 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.
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
POST /v1/flywheel/experiments/{id}/test/fundFUND 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.
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
POST /v1/flywheel/experiments/{id}/test/judgementsRECORD 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.
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
POST /v1/flywheel/experiments/{id}/test/evaluateEVALUATE 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_…).
curl -sS -X POST "$UR_BASE_URL/v1/flywheel/experiments/req_0000000000000000/test/evaluate" \
-H "Authorization: Bearer $UR_API_KEY"Abandon a live experiment
POST /v1/flywheel/experiments/{id}/abandonABANDON 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.
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
GET /v1/flywheel/confirmationsLIST 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_…).
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
GET /v1/flywheel/candidatesThe 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_…).
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
GET /v1/improvement-readsMULTI-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_…).
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
GET /v1/flywheel/knowledge/exportEXPORT 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.
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
GET /v1/flywheel/candidates/{ref}/confirmationThe 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_…).
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
GET /v1/flywheel/plansLIST 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_…).
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)
POST /v1/flywheel/plansA 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.
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)
GET /v1/flywheel/planThe 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_…).
curl -sS -X GET "$UR_BASE_URL/v1/flywheel/plan" \
-H "Authorization: Bearer $UR_API_KEY"Record a learning-plan review verb
POST /v1/flywheel/plan/{id}/reviewRecord 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.
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)
POST /v1/flywheel/plan/{id}/confirmCONFIRM 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.
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
POST /v1/flywheel/confirmations/{ref}/decisionThe 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.
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
POST /v1/flywheel/candidates/{runId}/acceptThe 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.
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
GET /v1/flywheel/prompt-candidatesLIST 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_…).
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
POST /v1/flywheel/prompt-candidates/{runId}/decisionThe 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.
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)
GET /v1/flywheel/reviewer/setupEverything needed to wire the ONE supported reviewer integration: the endpoint, the signed-webhook recipe (HMAC-SHA256 over "<unix-seconds>.<raw body>", 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_…).
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
POST /v1/flywheel/reviewer/testSends 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_…).
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)
POST /v1/flywheel/reviewer/eventsThe 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=<hex> over "<X-UR-Reviewer-Timestamp>.<raw body>", 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_…).
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
POST /v1/flywheel/reviewer/repairRe-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_…).
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)
GET /v1/flywheel/reviewer/healthLast 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_…).
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
POST /v1/flywheel/source-checksA 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_…).
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
POST /v1/flywheel/source-checks/{id}/verdictsThe 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_…).
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
GET /v1/flywheel/sourcesEvery 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:<import_id> 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_…).
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
POST /v1/flywheel/source-auditsSamples 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:<import_id>, 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.
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)
POST /v1/flywheel/source-audits/{id}/gradeCompares, 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.
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
POST /v1/flywheel/oraclesA 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_…).
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
GET /v1/flywheel/oracles/{id}The current version + every version, the verdict source oracle:<id>@<version>, 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_…).
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)
POST /v1/flywheel/oracles/{id}/testA 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_…).
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
POST /v1/flywheel/oracles/{id}/qualifyFrom 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_…).
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
POST /v1/flywheel/oracles/{id}/revokeAppends 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_…).
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)
POST /v1/flywheel/oracles/{id}/rotate-secretA 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.
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)
POST /v1/flywheel/oracles/{id}/jobs/claimYour 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=<hex HMAC of "<x-ur-oracle-timestamp>.<raw body>"> + 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_…).
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)
POST /v1/flywheel/oracles/{id}/verdictsOne 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_…).
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)
POST /v1/flywheel/candidates/{ref}/rolloutThe 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_…).
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)
POST /v1/flywheel/candidates/{ref}/decisionsA 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=<hex> over "<X-UR-AppCI-Timestamp>.<raw body>", 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_…).
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)
GET /v1/importsKeyset-paginated import metadata + capability reports (never OFFSET). Requires API-key scope: outcomes:write.
Auth: API key (Authorization: Bearer ur_sk_…).
curl -sS -X GET "$UR_BASE_URL/v1/imports" \
-H "Authorization: Bearer $UR_API_KEY"Import history (usage-only export or intact traces)
POST /v1/importsDay-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_…).
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)
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_…).
curl -sS -X GET "$UR_BASE_URL/v1/imports/req_0000000000000000" \
-H "Authorization: Bearer $UR_API_KEY"Cancel a non-terminal history import
POST /v1/imports/{id}/cancelCancel 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_…).
curl -sS -X POST "$UR_BASE_URL/v1/imports/req_0000000000000000/cancel" \
-H "Authorization: Bearer $UR_API_KEY"