ultrarouter docs
Guides

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 / pinned mode the recommended model is your own pin plus its fallbacks — served as written, with no policy score.
  • In candidates / auto mode it is the policy's ranked offer, with its per-factor score and a serialised why (the same decision the request path records).
  • evidence is a closed enum: qualified only when the policy input already carries qualified evals behind the choice, otherwise none. 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_version on the 5 s server regime. It carries a strong ETag and Cache-Control: private, max-age=5; send the ETag back as If-None-Match and an unchanged config answers 304. Any config change is visible within one window with a new config_version and a new ETag.
  • The live part — the health-ordered fallbacks — is the 30 s live regime, surfaced as routing.max_age_s: 30 and the X-UR-Live-Max-Age: 30 header.

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_holdout or pre_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-cost status=preflight served 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.