Declare what "it worked" means
Register an artifact type and its objective — the one thing ultrarouter refuses to infer. Append-only versions, idempotent re-runs.
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.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 preview under construction (local stack only, not on the hosted service). 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:writescope. New keys are granted it by default; a key created with an explicitscopeslist may omit it. Check withGET /v1/key. - Base URL and key exported:
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.
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:
{
"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.
# 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:
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 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 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.
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:
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_levelisunavailable(orrung_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_sourcedescribes how the objective was chosen (declared, orobserved/inferredwhen you pass no metric and a recommendation or site-brief seed is taken). It does not touch an outcome event's ownsource— imported history stayssource: imported. - A later confirm preserves your choice. Re-confirming the same workflow without
objective_metrickeeps 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:
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. |
Review and curate what the flywheel learns
An optional dashboard review queue for one call type's drafts — approve, edit, accept the AI reviewer's suggestion, or reject with a reason. Every verdict is a declared correction recorded through the existing outcome paths; the promise stays automated learning.
Record outcomes
The thesis walkthrough — declare the objective, then record outcomes five ways (instant, delayed, batch, warehouse backfill, flush) over the real SDK and raw REST, and report the moment you have the answer with a durable receipt. Server-assigned provenance, durable idempotent retries, never a body.