ultrarouter docs
Guides

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

The data flywheel and the learning loop run on the local stack only. They are off on the invite-only hosted gateway (the 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: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:
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

FieldRequiredWhat it is
metricyesOne 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.
unityesWhat the metric is counted in (boolean, ratio, usd, …).
directionyesWhich way is good (true_is_good, higher_is_better, lower_is_better, …).
resolution_criteriayesFree 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.
sourceyesWho or what resolves it (analyst, filing, an oracle name).
windowyesAn ISO-8601 duration (P12M, P30D, PT6H) — how long resolution may take, not a date.
predicate_idnoequals, within_tolerance, ge, le or resolved_by_source — a machine predicate over the resolution.
alternativesnoNamed 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:

StatusReasonMeaning
404call_type_not_foundNot one of this workspace's call types. A call type from another workspace reads the same — the tenant boundary hides it.
409unknown_workflowNo 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.
409ambiguous_workflowThe call type belongs to more than one derived workflow. The details list the candidate wfc_… ids; pass one as workflow_id.
409proposal_pendingThe workflow already has a pending proposal. Its plan_id is in the details — confirm that one. One pending proposal per workflow.
409workflow_confirmedThe 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_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: 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

SymptomCause and fix
403 permissionThe key is missing outcomes:write. Check GET /v1/key; mint or roll a key that includes it.
400 naming metricThe metric is outside the closed set. Pick one of the six above.
400 on windowwindow is a duration, not a date — P12M, not 2027-01-30.
400 with a missing fieldEvery field except predicate_id and alternatives is required.
The version did not increaseThe objective you sent is byte-equivalent to the current version (key order does not matter). That is the idempotent no-op, working as intended.