DOCUMENTATION · API v1

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.

shell
# 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.

shell
# 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.

POST /v1/route
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" }
  }'
response · 200 OK
{
  "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 Z offset. 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.

Request body
FieldTypeRequiredDescription
inputstring | objectyesThe task payload. Strings are classified directly; objects are classified from their text field and the rest is passed through untouched.
routestringnoRoute name to attribute the request to. If omitted, the runbook's match expressions decide, and attribution falls back to the calling key.
runbookstringnoRunbook to evaluate. Defaults to the key's default runbook. May be pinned to a version with name@7.
verifyobjectno{ 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_tierenumnosmall | mid | frontier. A ceiling the escalation ladder will not climb past. Exhausting it returns 422 no_eligible_route.
deadline_msintegernoWall-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.
streambooleannoWhen true, the response is a server-sent event stream. Equivalent to calling /v1/route/stream.
metadataobjectnoUp to 16 string keys, stored with the decision and returned by GET /v1/decisions/{id}. Not used in classification.
idempotency_keystringnoAlso 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.

Response fields
FieldTypeNotes
decision_idstringStable identifier, prefix dec_. Retained 30 days by default.
runbookstringAlways version-qualified on the way out, even if you did not pin it on the way in.
classificationobjectEvery scored dimension plus the composite score. This is the input to tiering, published so the tiering is checkable.
tierenumThe tier the request finally resolved at, not the one it started at.
targetstringModel class that produced the returned output.
fallbackstring[]The chain that was live for this decision, in order.
attemptsintegerTotal dispatches, across all tiers. Each one is a metered policy evaluation.
escalationsintegerRungs climbed. Zero on a healthy majority of traffic.
guards_firedstring[]Named guards that changed the outcome. Empty on most requests, and the first thing to read when a decision surprises you.
verifyobjectMode, the schema or rubric used, and the boolean result.
policy_eval_usintegerRouter time only. Excludes provider latency, because that is not ours to claim.
latency_msintegerEnd-to-end, including every attempt.
outputanyThe verified result, in the shape your verify clause demanded.

Other endpoints

EndpointPurpose
POST /v1/route/streamSame 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/runbooksRunbooks 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}/validateSchema and expression check plus an optional dry-run against a replay window. Returns a tier-drift report.
GET /v1/routesThe live router table: every route, its match, tier, fallback chain and observed p50.
GET /v1/routes/eventsRoute-change audit stream: policy deploys, automatic failovers, provider health transitions.
GET /v1/usageMetered 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-v2heuristic-v2 is deterministic and local. hybrid-v1 adds 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 in guards_fired.
tier.<name>.when
expression · requiredScore condition, or the literal default for 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, fallback and escalate_after. Evaluated top to bottom; first match wins.
verify.mode
enum · default: schemanone, schema, judge, schema+judge, schema+exec. Anything other than none makes escalation meaningful.
verify.sealed
boolean · default: falseWhen true, a request may not weaken verification with its own verify field. 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, or fail.
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 a queue.
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: monthday, week or month. Aligned to your billing period when set to month.
budget.guard
soft | hard · default: softSoft warns at warn_at and applies on_brake at brake_at. Hard refuses over-budget requests with 429 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.

expression grammar
# 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.

node · @ghostrouter/sdk
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; }
}
python · ghostrouter
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.

StatusCodeMeaning & what to do
400invalid_requestMalformed body or an unknown field. Not retryable — fix the call.
401unauthenticatedMissing, malformed or revoked key. Not retryable.
403runbook_forbiddenThe key is not scoped to the requested runbook. Refused at policy evaluation, so nothing is metered.
403residency_violationNo target satisfies the route's residency constraint. The router refuses rather than silently routing elsewhere.
409runbook_version_conflictA PUT raced another deploy. Re-read the deployed version, rebase your change and deploy again.
422no_eligible_routeClassification produced a tier the request's max_tier forbids, or every eligible target is unhealthy. Surface it; do not retry blindly.
429rate_limitedConcurrency ceiling reached. Retry with jittered backoff; Retry-After is always present.
429budget_exhaustedA hard budget guard refused the request. Retrying will not help until the period rolls or the runbook changes.
502provider_unreachableEvery target in the eligible chain failed to respond. Already retried internally per your fallback clause — safe to retry once at the application layer.
503router_drainingThe router instance is shedding load during a deploy. Retry immediately; another instance will take it.
504escalation_deadline_exceededdeadline_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.

idempotent retry
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