Preflight a non-proxied call
Ask the gateway, before a call it will never see, which model and fallbacks it would route, which learned knowledge it would inject — and where — and get your exposure grant in the same response. One read, two cache lifetimes, an honest provisional flag, and an uncached grant.
Most of ultrarouter's decisions happen on the request path: you proxy a call, and the gateway picks the model, orders the fallbacks, and injects the learned-context module your workspace opted into. Preflight hands you that same decision for a call the gateway will never see — so an agent that dispatches to a provider directly can still ask, up front, "which model, which fallbacks, and which learned knowledge should I inject, and where?" and get back exactly what a proxied request would have gotten under the same engine.
There is one engine. Preflight computes nothing a proxied request would not: the
routing decision is the same decideRoutePolicy / planRoute path, and the learned
module is resolved through the same resolve → rebuild → admit function the
injection seam runs. It cannot name a model or a module the request path would not.
The request
POST /v1/preflight
Authorization: Bearer ur_sk_live_… # the gateway:invoke scope — a read of what an invoke would do
Content-Type: application/json
{
"call_type": "support-reply",
"artifact_key": "ticket-cluster-42", // OR "trace_id" — supply one to receive a grant
"budget": { "max_usd": 5, "max_latency_ms": 4000 } // optional pre-call hints
}call_type selects the profile to preflight. A cluster key — artifact_key or
trace_id — is what the exposure grant is minted on: supply one and the response
carries your grant; supply neither and you still get the routing and knowledge advice,
with grant: null and a named grant_reason (no_cluster_key). The hosted_prompt
field is reserved for a later release and is not emitted.
The response
{
"object": "preflight",
"config_version": 7,
"routing": {
"mode": "auto",
"model": { "id": "openai/gpt-5-mini", "evidence": "none", "score": { … } },
"fallbacks": [ { "id": "anthropic/claude-3-5-haiku", "score": { … } } ],
"why": { … },
"max_age_s": 30
},
"knowledge": {
"module": {
"id": "lcm_…", "version": "lcm1_…", "kind": "ur:learned", "slot": "learned",
"content_hash": "…", "tokens": 118, "content": "Learned from this workspace's …",
"lineage": { "pack_hash": "lcp1_…", "epoch": 3, "item_ids": ["kf_…"], … }
},
"placement": "after_stable_prefix",
"delivery": "off"
},
"knowledge_reason": null,
"grant": {
"grant_id": "grant_9f2c…", "arm": "treated", "lever_id": null, "epoch": 3,
"valid_until_epoch_close": "2026-09-17T19:00:00.000Z",
"request_id": "req_…"
},
"grant_reason": null,
"provisional": true,
"reason": "not_benchmarkable"
}What model honestly means
- In
off/pinnedmode the recommendedmodelis your own pin plus its fallbacks — served as written, with no policy score. - In
candidates/automode it is the policy's ranked offer, with its per-factorscoreand a serialisedwhy(the same decision the request path records). evidenceis a closed enum:qualifiedonly when the policy input already carries qualified evals behind the choice, otherwisenone. Preflight introduces no new estimator, and it makes no per-task or quality-bar claim — a call type earns task-mode language only through its own proof, never through this read.
Knowledge is the module, or a named reason — never a silent null
knowledge is the learned-context module the injection seam would splice, rendered
here as advisory data with its content_hash, token count and lineage, plus the one
placement rule (after_stable_prefix). When nothing would inject, knowledge is
null and knowledge_reason names why — no_learned_module when the profile
declares none, or the seam's own refusal (holdout, module_version_mismatch,
candidate_unknown, …) when a module was considered and withheld.
The standing holdout is honoured exactly as for a proxied call: when your grant's
arm is holdout (or lever_holdout), the module is withheld from the same response —
knowledge is null with knowledge_reason: "holdout", so a held-out call never even
receives the learned block. When you preflight without a cluster key there is no
arm to honour, so knowledge shows what a treated request would receive.
Preflight answers within the same time budget as a proxied call. The module is normally
read from the gateway's current configuration generation — the same copy a proxied
request injects, so the advice and the dispatch agree. If the module has to be fetched
fresh and that fetch cannot finish within the gateway's hot-path budget, the response
is still returned (routing and grant included) with knowledge: null and
knowledge_reason: "fell_back". Treat it like any other named refusal: send the call
without the learned block, or preflight again.
delivery describes what the gateway does on a proxied request for this call
type — it does not change what you do here. It is set per call type on the Self-improve
settings card: gateway_inject_after_stable_prefix (the default — a proxied request has
the gateway inject the module for you) or off (the gateway never injects, even on a
proxied request). On this non-proxied path the gateway never sees your call, so you
always inject the returned module yourself at placement, regardless of delivery —
useLearned().apply() / use_learned().apply() does exactly that. The only reason to
withhold the module is a holdout arm (above); delivery is advisory context, never a
signal to skip injecting. Flipping delivery starts a new measurement epoch (and cancels
any confirmation currently running for the call type), so the config_version moves
when you change it.
If you both inject the module client-side and proxy the same request on the default
gateway_inject_after_stable_prefix delivery, the gateway does not add a second
copy: it detects that the request already carries the module (by the block's identity)
and injects nothing, stamping the request already_present — or other_version_present
if the block you carry is a different (stale) version. It never strips or replaces the
block you sent. So a mixed setup stays correct, but pick one path per call type to keep
the intent clear.
provisional tells you how much to trust the read
provisional is true, with a reason, whenever the advice carries a caveat:
plan_unconfirmed (the call type has no confirmed plan yet), not_benchmarkable
(no qualified evidence backs the choice), or budget (your budget hints exclude
the pin or offer). It is false only when a confirmed, benchmarkable choice fits.
Two cache lifetimes
The response has two parts with different freshness:
- The config-versioned part — the model, the pin/candidates and the knowledge
module — is keyed by
config_versionon the 5 s server regime. It carries a strongETagandCache-Control: private, max-age=5; send theETagback asIf-None-Matchand an unchanged config answers304. Any config change is visible within one window with a newconfig_versionand a newETag. - The live part — the health-ordered
fallbacks— is the 30 s live regime, surfaced asrouting.max_age_s: 30and theX-UR-Live-Max-Age: 30header.
An SDK caches each part by its own max_age_s and revalidates the config part by
ETag. The two never share a lifetime, and there is no change channel to subscribe to.
The grant is never cached. It is deliberately excluded from the ETag, and it is
served on the uncached X-UR-Grant response header as well as in the body — so a
304 revalidation, which returns no body, still carries a fresh, correct grant on
that header. Never cache the grant with the config-versioned body; read it fresh from
each response.
The exposure grant — feed the flywheel from a call we never saw
When you supply a cluster key, preflight mints your exposure grant — the same
deterministic assignment the gateway stamps at dispatch, minted by the same sanctioned
owner as POST /v1/flywheel/grants (never a second arm assignment). One call gives you
both what to apply and the arm you are in:
grant_id— deterministic (a hash of workspace + cluster key + epoch), so the same key in the same epoch always mints the same grant; re-minting writes no second record.arm—treated,holdout,lever_holdoutorpre_lever. When it is a holdout arm the learned module is withheld above (do not apply it).lever_id,epoch,valid_until_epoch_close— re-mint on a newer epoch; the horizon is conservative, and the grant is never cached.request_id— the zero-coststatus=preflightserved record preflight wrote, so the outcome you record later has a served denominator.
Then close the loop: call your model directly, and record the outcome quoting the grant so the artifact is armed under that arm and counted like a proxied one:
POST /v1/artifacts
{ "key": "ticket-cluster-42", "grant_id": "grant_9f2c…", "attestation": "applied" }attestation: "applied" (customer-attested — shown beside lift, never folded into
provenance) tells us you actually used the recommended module/model; the default is
served. Recording the grant on the artifact is what lets a call the gateway never
proxied feed the flywheel exactly like one it did.
Set up with Claude Code or Codex
Copy-to-agent setup prompts for Claude Code and Codex, generated from versioned setup recipes, and what really differs between the two agents.
Import history
Bring past traffic and resolutions into the ledger without the proxy — usage-only vs intact-trace classes, the capability report, per-record idempotency, repairable per-row receipts, and binding history to a confirmed call type so its verdicts can count toward readiness. Nothing is guessed; nothing is trained.