Route a request in three steps.
Everything the router does is reachable over plain HTTPS. These pages document the wire format, the runbook language and the errors — at the level of detail you would want before putting something in front of production traffic.
Quickstart
GhostRouter sits between your application and the models it calls. You send a request describing a task; the router classifies it, evaluates your runbook, dispatches to the cheapest correct target, verifies the output and returns a decision object. Three steps below take you from nothing to a routed request.
Base URL
api.ghostrouter.io/v1 — TLS 1.3 only; plaintext connections are refused. Every example on this page uses $GR_API, which the SDK installer and the CLI both write into your environment.
1 · Install
Official clients exist for Node and Python. Everything the clients do is available over plain HTTP, so a curl-only integration is a supported path, not a fallback.
# node
npm install @ghostrouter/sdk
# python
pip install ghostrouter
# cli (runbook validation, dry-runs, decision inspection)
npm install -g @ghostrouter/cli
2 · Authenticate
Keys are issued per environment and scoped to a set of runbooks. A key that cannot execute a runbook is refused at policy evaluation, not at dispatch — you will never be billed for a request that was never eligible to route.
# written by `gr login`; both variables are read by every client
export GR_API=api.ghostrouter.io/v1
export GHOSTROUTER_KEY=gr_live_"<issued to you>"
# verify the key and see which runbooks it can execute
gr whoami
# org acme-industrial
# key gr_live_…8f21 (env: production)
# runbooks default-v3, reporting-pipeline@7
3 · First routed request
Send a task. You do not choose a model — that is the entire product. You may choose a ceiling with max_tier if a route must never get expensive.
curl -sS "$GR_API/route" \
-H "Authorization: Bearer $GHOSTROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"route": "extract.invoice-lines",
"runbook": "reporting-pipeline",
"input": "Invoice 4471 — 12 line items, net 30.",
"verify": { "mode": "schema", "schema": "invoice.v2" }
}'
{
"decision_id": "dec_7QF3M0RTY7K2",
"runbook": "reporting-pipeline@7",
"route": "extract.invoice-lines",
"classification": {
"kind": "extraction",
"reasoning": 0.18, "risk": 0.10,
"ambiguity": 0.24, "output_len": 0.31,
"score": 0.21
},
"tier": "small",
"target": "small-b",
"fallback": ["small-c", "mid-a"],
"attempts": 1,
"escalations": 0,
"verify": { "mode": "schema", "schema": "invoice.v2", "passed": true },
"policy_eval_us": 174,
"latency_ms": 238,
"output": {
"invoice_no": "4471", "lines": 12, "terms": "net 30"
}
}
API conventions
The API is JSON over HTTPS. It is versioned in the path (/v1) and the runbook language is versioned separately (runbook/v3) — a schema bump never breaks a wire call, and a wire bump never invalidates a policy.
- Authentication is
Authorization: Bearer $GHOSTROUTER_KEY. There is no query-parameter form, ever. - Timestamps are RFC 3339 with a
Zoffset. Durations are suffixed with their unit in the field name (_ms,_us). - Every response carries
GR-Decision, so a decision can be recovered from a log line months later. - Requests are limited by concurrency, not by request rate. The limit is set in your agreement and surfaced in
GR-Concurrency-Remaining.
POST/v1/route
Route one task and return the decision. This is the endpoint nearly all traffic uses.
| Field | Type | Required | Description |
|---|---|---|---|
| input | string | object | yes | The task payload. Strings are classified directly; objects are classified from their text field and the rest is passed through untouched. |
| route | string | no | Route name to attribute the request to. If omitted, the runbook's match expressions decide, and attribution falls back to the calling key. |
| runbook | string | no | Runbook to evaluate. Defaults to the key's default runbook. May be pinned to a version with name@7. |
| verify | object | no | { mode, schema?, rubric?, harness? }. Overrides the runbook's verify clause for this request only. Overriding to a weaker mode is refused if the runbook marks verification as sealed. |
| max_tier | enum | no | small | mid | frontier. A ceiling the escalation ladder will not climb past. Exhausting it returns 422 no_eligible_route. |
| deadline_ms | integer | no | Wall-clock budget for the whole decision, escalations included. On expiry the router returns its best verified result, or 504 escalation_deadline_exceeded if it has none. |
| stream | boolean | no | When true, the response is a server-sent event stream. Equivalent to calling /v1/route/stream. |
| metadata | object | no | Up to 16 string keys, stored with the decision and returned by GET /v1/decisions/{id}. Not used in classification. |
| idempotency_key | string | no | Also accepted as the Idempotency-Key header. See retries. |
The decision object
Every route call returns the same shape, whether it resolved on the first attempt or climbed the whole ladder. The fields below are stable within v1; additive fields may appear and should be ignored if unknown.
| Field | Type | Notes |
|---|---|---|
| decision_id | string | Stable identifier, prefix dec_. Retained 30 days by default. |
| runbook | string | Always version-qualified on the way out, even if you did not pin it on the way in. |
| classification | object | Every scored dimension plus the composite score. This is the input to tiering, published so the tiering is checkable. |
| tier | enum | The tier the request finally resolved at, not the one it started at. |
| target | string | Model class that produced the returned output. |
| fallback | string[] | The chain that was live for this decision, in order. |
| attempts | integer | Total dispatches, across all tiers. Each one is a metered policy evaluation. |
| escalations | integer | Rungs climbed. Zero on a healthy majority of traffic. |
| guards_fired | string[] | Named guards that changed the outcome. Empty on most requests, and the first thing to read when a decision surprises you. |
| verify | object | Mode, the schema or rubric used, and the boolean result. |
| policy_eval_us | integer | Router time only. Excludes provider latency, because that is not ours to claim. |
| latency_ms | integer | End-to-end, including every attempt. |
| output | any | The verified result, in the shape your verify clause demanded. |
Other endpoints
| Endpoint | Purpose |
|---|---|
| POST /v1/route/stream | Same body as /v1/route; responds with SSE. Escalations are announced as they happen, so a UI can say "still working" honestly. |
| GET /v1/decisions/{id} | Full trace: classification, every attempt, every guard, every verification result, with timings. |
| GET /v1/runbooks | Runbooks the calling key may execute, with deployed versions. |
| PUT /v1/runbooks/{name} | Deploy a new immutable version. Returns 409 runbook_version_conflict if the base version moved under you. |
| POST /v1/runbooks/{name}/validate | Schema and expression check plus an optional dry-run against a replay window. Returns a tier-drift report. |
| GET /v1/routes | The live router table: every route, its match, tier, fallback chain and observed p50. |
| GET /v1/routes/events | Route-change audit stream: policy deploys, automatic failovers, provider health transitions. |
| GET /v1/usage | Metered quantities for a period, by unit and by route. Quantities only — no rates, no amounts. |
A deliberate omission
There is no endpoint that returns money. GET /v1/usage returns quantities, the portal dashboard displays quantities, and amounts appear on your monthly invoice. This is a boundary, not an oversight.
Runbook schema · runbook/v3
A runbook is YAML. Every key below is documented with its type and default; anything not listed is rejected at validation rather than ignored, so a typo fails the build instead of quietly disabling a guard.
- name
- string · requiredStable identifier. Referenced by API calls and stamped on every decision.
- version
- integer · requiredMonotonic. Deploying a version that already exists is refused — runbooks are immutable once live.
- extends
- string · optionalBase runbook to inherit from. Child clauses replace parent clauses wholesale; there is no deep merge, because deep merges are how policies become unreadable.
- classify.dimensions
- string[] · default: [reasoning, risk, ambiguity, output_len]Which scores the evaluator emits. Available:
reasoning,risk,ambiguity,output_len,mechanical,lingual,creative,code. - classify.evaluator
- enum · default: heuristic-v2
heuristic-v2is deterministic and local.hybrid-v1adds a small-tier model pass for ambiguous inputs and costs one extra policy evaluation. - classify.weights
- map · optionalPer-dimension weights for the composite score. Must sum to 1.0. Defaults are
reasoning 0.42,risk 0.26,ambiguity 0.18,output_len 0.14. - guards[]
- rule[] · optionalEvaluated in order before tier mapping. Each rule is
{ when, floor_tier | pin_tier | reject }. A guard that fires is named inguards_fired. - tier.<name>.when
- expression · requiredScore condition, or the literal
defaultfor the catch-all tier. Exactly one tier must be the default. - tier.<name>.targets
- string[] · requiredInterchangeable model classes. Order is not preference — selection is by health and observed p50 over a 60-second window.
- routes.<name>
- map · optionalNamed route entries with their own
match,tier,verify,fallbackandescalate_after. Evaluated top to bottom; first match wins. - verify.mode
- enum · default: schema
none,schema,judge,schema+judge,schema+exec. Anything other thannonemakes escalation meaningful. - verify.sealed
- boolean · default: falseWhen true, a request may not weaken verification with its own
verifyfield. Use on anything regulated. - escalate_after
- integer · default: 2Verified failures permitted at a tier before climbing exactly one rung.
- max_tier
- enum · optionalCeiling for the ladder. Reaching it without a pass triggers
on_exhausted. - deadline_ms
- integer · optionalWall-clock budget for the whole decision. Pairs well with
on_deadline: return_best_effort. - fallback.provider_down
- enum · default: next_targetWhat to do when a target is unreachable:
next_target,escalate, orfail. - fallback.tier_exhausted
- enum · default: escalateBehaviour when every target in a tier has failed verification.
- fallback.exhausted
- enum | string · default: failEnd of the ladder. Commonly
human-review, which requires aqueue. - canary
- map · optional
{ tier, share, promote_if }. Mirrors a share of live traffic one tier down and compares verification pass rates. - residency
- string · optionalRegion constraint on target selection. A violation is refused with
403 residency_violation, never silently re-routed. - budget.period
- enum · default: month
day,weekormonth. Aligned to your billing period when set tomonth. - budget.guard
- soft | hard · default: softSoft warns at
warn_atand applieson_brakeatbrake_at. Hard refuses over-budget requests with429 budget_exhausted. - budget.on_brake
- action · default: pin_tier(small)Applied while the period is over its allowance. Resets automatically at period roll.
Expressions
Match and guard expressions are a small, total language: comparisons, boolean operators, and membership. There are no function calls, no loops and no user-supplied code, which is why evaluation is measured in microseconds and why a policy cannot hang a request.
# operands
score reasoning risk ambiguity output_len mechanical lingual creative code
kind route input_tokens tags attempts budget.period_used
# operators
== != < <= > >= and or not contains in
# examples
"kind == extraction and schema_bound"
"risk > 0.60"
"tags contains pii and residency != us"
"kind in [translation, classification] and input_tokens < 4000"
"budget.period_used > 1.00"
SDKs
Both official clients are thin: they set headers, serialise the body, and give you a typed decision object. Neither one makes routing choices locally, because a client that could second-guess the policy would defeat the policy.
import { GhostRouter, RouterError } from "@ghostrouter/sdk";
const gr = new GhostRouter({ apiKey: process.env.GHOSTROUTER_KEY });
try {
const dec = await gr.route({
route: "summarize.thread",
input: thread.text,
max_tier: "mid",
deadline_ms: 4000
});
render(dec.output);
} catch (e) {
if (e instanceof RouterError && e.code === "no_eligible_route") {
// the ceiling was too low for this task — surface, do not retry
queueForReview(thread);
} else { throw e; }
}
from ghostrouter import Router, RouterError
router = Router() # reads GHOSTROUTER_KEY and GR_API
for doc in batch:
try:
dec = router.route(
route="extract.invoice-lines",
input=doc.text,
idempotency_key=doc.sha256,
)
except RouterError as e:
if e.code == "budget_exhausted":
break # the runbook has spoken; stop the batch
raise
store(doc.id, dec.output, dec.decision_id)
Errors
Every error body is { "error": { "code", "message", "decision_id?" } }. Codes are stable strings; messages are for humans and may change.
| Status | Code | Meaning & what to do |
|---|---|---|
| 400 | invalid_request | Malformed body or an unknown field. Not retryable — fix the call. |
| 401 | unauthenticated | Missing, malformed or revoked key. Not retryable. |
| 403 | runbook_forbidden | The key is not scoped to the requested runbook. Refused at policy evaluation, so nothing is metered. |
| 403 | residency_violation | No target satisfies the route's residency constraint. The router refuses rather than silently routing elsewhere. |
| 409 | runbook_version_conflict | A PUT raced another deploy. Re-read the deployed version, rebase your change and deploy again. |
| 422 | no_eligible_route | Classification produced a tier the request's max_tier forbids, or every eligible target is unhealthy. Surface it; do not retry blindly. |
| 429 | rate_limited | Concurrency ceiling reached. Retry with jittered backoff; Retry-After is always present. |
| 429 | budget_exhausted | A hard budget guard refused the request. Retrying will not help until the period rolls or the runbook changes. |
| 502 | provider_unreachable | Every target in the eligible chain failed to respond. Already retried internally per your fallback clause — safe to retry once at the application layer. |
| 503 | router_draining | The router instance is shedding load during a deploy. Retry immediately; another instance will take it. |
| 504 | escalation_deadline_exceeded | deadline_ms elapsed with no verified result. The partial trace is retrievable by decision_id. |
Retries & idempotency
The router already retries inside a decision — that is what the escalation ladder is. Application-level retries are for transport failures only, and they should carry an idempotency key so a retry joins the original decision instead of starting a second one.
curl -sS "$GR_API/route" \
-H "Authorization: Bearer $GHOSTROUTER_KEY" \
-H "Idempotency-Key: doc-9f21c4e0" \
-H "Content-Type: application/json" \
-d '{ "route": "extract.invoice-lines", "input": "…" }'
# A repeat within 24h returns the ORIGINAL decision, including its
# decision_id, and is not metered a second time. Keys are scoped to
# the calling key and are not shared across environments.
api v1 · schema runbook/v3 · last reviewed by routing engineering