ground/router

Documentation

One search API over every grounding provider. Route by configuration, not code.

Quickstart

Every request goes to one endpoint. Which providers answer it is decided by the routing config bound to your API key — not by anything in the request.

terminal
curl https://api.groundrouter.ai/v1/search \
  -H "Authorization: Bearer $GROUNDROUTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "offshore wind capacity 2026", "top_k": 5}'

Authentication

Send your key as a bearer token. Keys are shown once at creation and stored only as a hash — a lost key must be rotated, not recovered.

header
Authorization: Bearer gr-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Two separate credentials

A search key cannot mint keys or change routing policy. Control-plane calls under /v1/admin require a separate admin token, so a leaked search key does not escalate to account control.

MCP server

GroundRouter is an MCP server over Streamable HTTP. Most harnesses need one line and no SDK.

terminal
claude mcp add --transport http groundrouter \
  https://api.groundrouter.ai/mcp \
  --header "Authorization: Bearer $GROUNDROUTER_KEY"

Two tools are exposed:

ToolArgumentsPurpose
searchquery, top_k, domains, days, include_textSearch every routed provider. Returns compact text with rank, URL, provenance tags and passage.
list_providersWhich providers this key can reach and what each can filter on. Use after an unsupported_filter error.

The search tool takes flattened arguments (days: 7 rather than a nested recency object) because agents write more reliable calls against a flat schema.

POST /v1/search

POSThttps://api.groundrouter.ai/v1/search

Request

FieldTypeDescription
query requiredstringThe search query. 1–2000 characters.
top_kintegerResults to return after merging. 1–100, default 10.
include_textbooleanInclude passage text. Default true. Set false to cut latency and response size.
filters.domainsobject{ values: string[], mode: "include" | "exclude" }
filters.recencyobject{ mode: "rolling", days: n } or { mode: "range", from, to }
filters.publisher_idsobjectLicensed corpus only. Ozone publisher IDs.
filters.article_typesobjectLicensed corpus only, e.g. hard_news, analysis.
filters.languagestringISO language code.
providersstring[]Override the bound routing policy for this request. For experiments; normally omitted.

Response

200 OK
{
  "query": "who won the 2026 UK general election",
  "results": [
    {
      "rank": 3,
      "url": "https://www.bbc.com/news/articles/...",
      "domain": "bbc.com",
      "canonical_url": "bbc.com/news/articles/...",
      "title": "Election 2026: results in full",
      "text": "...",
      "score": 0.74,
      "published_date": "2026-05-02",
      "provenance": {
        "provider": "ozone",
        "found_by": ["tavily", "ozone"],
        "licensed": true,
        "publisher_id": "OZONEBBC4784",
        "publisher_name": "BBC",
        "promoted": "ozone_precedence"
      }
    }
  ],
  "routing": {
    "policy": "fanout",
    "calls": [
      { "provider": "tavily", "status": "ok", "latency_ms": 985, "cost_units": 1 },
      { "provider": "ozone",  "status": "ok", "latency_ms": 412 }
    ]
  },
  "usage": { "latency_ms": 1002, "cached": false, "licensed_result_count": 1 },
  "request_id": "..."
}

routing.calls reports every provider attempt, including failures. Under fanout a partial failure still returns 200 with the results that arrived — check this array rather than assuming every provider answered.

score vs rank

score is the provider’s own relevance, as reported, and is not comparable across providers. rank is the merged ordering and is the only cross-provider signal. Sort on rank.

GET /v1/providers

GEThttps://api.groundrouter.ai/v1/providers

The live catalogue: every provider, its filter capabilities, and whether your account holds working credentials for it (configured).

Errors

Errors carry a stable machine-readable code. Branch on that, never on the message text.

CodeStatusMeaning
unauthorized401Missing, invalid or revoked API key.
invalid_request400Malformed body or parameter out of range.
unsupported_filter400A routed provider cannot honour a filter you sent. See below.
rate_limited429Per-minute limit exceeded. See X-RateLimit-Reset.
no_provider_available502Every routed provider failed. details.calls says why.
provider_error502An upstream provider failed and no fallback remained.

Routing policies

PolicyShapeBehaviour
singleproviderOne provider. Errors surface directly.
failoverproviders[]Ordered; first success wins. Cheapest resilience.
rotate{provider, weight}[]Weighted per request. Spreads spend across quotas.
fanoutproviders[], timeout_msAll in parallel, merged and deduplicated. Best recall; the only policy where cross-provider licence precedence applies.
Failover is not blind retry

A missing credential moves to the next provider — that says nothing about whether the next one works. A rejected credential or a malformed request does not: it would fail identically downstream, and continuing spends another provider’s quota to hide a configuration error you need to fix.

Filter capabilities

Providers do not support the same filters. publisher_ids, article_types and temporal_sensitivity describe a licensed corpus and are meaningless on the open web.

Refusal, not silent degradation

Sending publisher_ids to a config routed at Tavily returns 400 unsupported_filter. It does not return unfiltered results.

The alternative would be worse than an error: you would receive plausible-looking results that violate the constraint you set, with no way to tell. Set strict_capabilities: false on the config to drop unsupported filters instead — the response still reports what was dropped.

Deduplication and ranking

Canonicalisation

Providers return the same document under different URLs. GroundRouter collapses tracking parameters (utm_*, fbclid, gclid…), www./m./amp. hosts, AMP path suffixes and trailing slashes, and sorts remaining query parameters.

Unknown query parameters are kept. For many sites ?id=123 is the document identity, and merging those would serve one article in place of two. Over-merging is the more damaging direction, so canonicalisation is deliberately conservative.

Rank fusion

Results are fused by rank using Reciprocal Rank Fusion, not by score. Provider scores come from different models over different corpora, so combining them arithmetically would assert a comparability that does not exist. Rank is the one thing every provider agrees on the meaning of.

A document several providers rank highly therefore outranks one only a single provider liked.

Routing configs

A config is data, not code. Update it and the next request routes differently — no redeploy, no client release.

PUT/v1/admin/configs/:id
config
{
  "name": "production",
  "routing": {
    "policy": "fanout",
    "providers": ["ozone", "exa", "tavily"],
    "per_provider_top_k": 10,
    "timeout_ms": 6000
  },
  "merge": { "strategy": "precedence" },
  "strict_capabilities": true,
  "cache_ttl_seconds": 300
}

Configs are resolved in order: the config bound to the API key, then your account default, then a built-in fanout over whatever providers hold credentials. The account default is what lets a whole fleet of keys be re-routed with one edit.

Validation happens on write. A provider name that does not exist, or one that is inactive, is rejected at edit time rather than surfacing later as a failed search.

Bring your own keys

GroundRouter does not resell search. You supply your own Exa, Tavily and Brave keys; they are encrypted with AES-256-GCM under a key held outside the database.

PUT/v1/admin/credentials
terminal
curl -X PUT https://api.groundrouter.ai/v1/admin/credentials \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"provider": "tavily", "api_key": "tvly-..."}'

The response returns a fingerprint and the last four characters only. No endpoint returns a stored credential. A dashboard needs to show which key is configured, not the key itself — otherwise a compromised admin token would exfiltrate every provider key in one request.

Your stored credential takes precedence over any platform key. Ozone is the exception: it is GroundRouter’s own upstream, reached with a GroundRouter-issued token.

API keys

EndpointMethodPurpose
/v1/admin/keysPOSTIssue a key. The plaintext is returned once and never again.
/v1/admin/keysGETList keys by prefix, with last-used timestamps.
/v1/admin/keys/:idPATCHRebind to a different routing config.
/v1/admin/keys/:idDELETERevoke. Soft delete, so usage history survives.

Content licensing

Ozone holds content licences from its publisher network. The commodity search providers do not.

When any provider returns a URL on an Ozone publisher domain, Ozone’s record is served for it — its licensed passage text and publisher metadata, rather than a scraped snippet — and the result is marked licensed: true with promoted: "ozone_precedence". A result Tavily found on a BBC page is still licensed content; which provider surfaced it does not change who owns it.

Licensing does not distort ranking

Licensed content receives no ranking advantage over content Ozone does not own. It ranks where the rank fusion puts it. An agent that cannot trust the ordering stops using the router, so the ordering stays honest and the licence is reported as metadata.

If you want licensed content positioned differently, that is an explicit merge strategy (top_slots or interleave) you opt into per config — never a hidden default.

Rate limits and caching

Limits are per minute, reported on every response:

response headers
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 119
X-RateLimit-Reset: 1788529560
X-Cache: MISS

Responses are cached per cache_ttl_seconds. The cache key covers the request and the routing and merge policy, so editing a config never serves results produced under the old one. Keys are also account-scoped, so no result crosses between accounts.