---
name: search-router
description: >
  Meta-search over a fan-out of providers (web search and deep-research
  agents) through one API: POST a query, get ranked results with sources,
  costs, and a trace id. Use for questions needing fresh or broad web
  evidence. Needs a SEARCHROUTER_API_KEY — or register as an agent and get
  one with no credential at all (see "Getting a key without a human").
---

# search-router

This file describes the client contract at the version below (the `info.version` of the
OpenAPI file, served at `https://platform.nyks.dev/search-router-openapi.yaml`; the tables
between the `gencontract` markers are generated from it).

<!-- gencontract:begin version -->

**Version 2026-10-23.**

<!-- gencontract:end version -->

Three hosts, one key. The search API and the agents host serve the routes below; the third
host, `https://platform.nyks.dev`, is the portal humans sign in to, and the claim page at
`/claim/…`.

<!-- gencontract:begin endpoints -->

| method · path | host | auth | spend-free | what it does |
|---|---|---|---|---|
| `POST /v1/search` | router-api | `sr-` key · scope `search` | no | One query, fanned out to every configured provider, ranked. |
| `POST /v1/search/stream` | router-api | `sr-` key · scope `search` | no | The same fan-out, streamed as NDJSON in completion order. |
| `GET /v1/traces` | router-api | `sr-` key · scope `read` | yes | Your requests, newest first (tenant-scoped), one page at a time. |
| `GET /v1/traces/{id}` | router-api | `sr-` key · scope `read` | yes | One stored trace — the exact response document, plus live thinking. |
| `POST /v1/feedback` | router-api | `sr-` key · scope `search` | no | Attach implicit feedback labels to a trace (fire-and-forget). |
| `POST /v1/fetch` | router-api | `sr-` key · scope `search` | no | The full text of up to five pages from your own recent search results. |
| `POST /v1/turns` | router-api | `sr-` key · scope `search` | yes | Report that a turn of an agent session ended, optionally with its conversation. |
| `POST /v1/judge` | router-api | `sr-` key · scope `search` | no | Experimental: grade one of your stored traces with an LLM judge. Not calibrated yet; full support will follow. |
| `GET /v1/limits` | router-api | `sr-` key · scope `read` | yes | Read this project's spend caps and its spend today, this week and this month against them. |
| `POST /v1/limits` | router-api | `sr-` key · scope `limits` | yes | Set this project's daily/weekly/monthly spend caps. |
| `GET /v1/account` | router-api | `sr-` key · scope `read` | yes | What the gateway knows about the calling key and its tenant. |
| `GET /healthz` | router-api | none | — | Public readiness probe. |
| `POST /v1/agents/register` | agents-v1 | none | — | An agent mints its own key on a small unclaimed tier — no credential. |
| `GET /v1/agents/me` | agents-v1 | `sr-` key | — | Self-lookup by key — rebuild a lost record. |
| `GET /v1/agents/{agent_id}` | agents-v1 | `sr-` key | — | The agent's status poll (unclaimed / claimed / expired). |
| `POST /v1/agents/{agent_id}/claim-code` | agents-v1 | `sr-` key | — | Recover the human claim link — mint a FRESH claim code (key-authed). |
| `POST /v1/agents/enroll` | agents-v1 | `sr-` key | — | A claimed agent mints a mining enrollment token. |

Hosts: `router-api` = `https://router-api.nyks.dev` — Search data plane (search, fetch, traces, feedback, judge, limits, turns, account; the public /healthz); `agents-v1` = `https://agents-v1.nyks.dev` — Public agent onboarding (register, me, poll, claim-code, enroll).
Auth: an `sr-` key is `Authorization: Bearer sr-…`; its scope is the one of the key's scopes that covers the route — a key with no scopes covers them all.
Spend-free: exempt from the two budget refusals, `402 credits_exhausted` and `402 spend_ceiling`; rate and concurrency limits still apply. `—` where the gate does not meter.

<!-- gencontract:end endpoints -->

Auth: `Authorization: Bearer $SEARCHROUTER_API_KEY` on every request except the two marked
`none`. Keys start with `sr-`. Never put the key in a URL, a log line, or a query string.
All three hosts sit behind Cloudflare: send a real `User-Agent` (Python's default
`urllib` agent gets challenged; curl and Go defaults pass).

**Key scopes.** The owner of a project may mint a key restricted to a subset of `search`,
`read` and `limits` — the `auth` column above says which one a route needs; a key with no
scopes may do everything. A scoped key outside its scope gets `403 scope` (the message names
the missing scope) before any rate counter moves. Your own key's scopes are in
`GET /v1/account` (below). An agent key's claim-time scopes (`credits`, `mining`) are a
different vocabulary — the control plane's grants — and do not restrict the search API.

`GET /healthz` needs no key and says only `{"ok":true}` when the gateway is serving
(`503 not_ready` while it starts or drains). Use it for "is it up", nothing else: it carries
no version, no provider list, and is counted against nothing.

## Getting a key without a human

If you have no key, register yourself. No credential is needed and the key
works at once on a small "unclaimed" tier; one visit by a human to the claim
link lifts it into their organization.

```
POST https://agents-v1.nyks.dev/v1/agents/register
{"name": "my-agent", "client": "my-tool/1.0", "requested_scopes": ["mining"]}
```

All fields are optional and bounded (`name` ≤ 64 chars, `client` ≤ 128);
`requested_scopes` is only a hint that pre-ticks a box on the claim page —
it grants nothing. Response `201`:

```
{"agent_id": "…", "key": "sr-…",
 "claim_url": "https://platform.nyks.dev/claim/AB12-CD34", "claim_code": "AB12-CD34",
 "claim_expires_at": "…", "poll": {"url": "https://agents-v1.nyks.dev/v1/agents/…", "interval_s": 5},
 "tier": "unclaimed"}
```

- **Save `key` now.** Like every key here it is shown once.
- **Unclaimed tier** (defaults, operator-tunable): `tier: "fast"` only, a
  $0.10 daily spend ceiling (about six fast searches), 10 requests per minute
  and 120 per hour, 2 concurrent requests, metadata-only traces, and the key stops working
  **7 days** after registration unless claimed. Enough to prove the
  integration, not to run on.
- **Hand the human `claim_url`** (or the code; it is case-insensitive, hyphen
  optional). They sign in at the portal and claim the agent into their
  organization, choosing the scopes `credits` (spend the org's credit) and/or
  `mining` (Twilight mining enrollment). After the claim the key has the
  org's credit, every tier, and the standard limits: 120 requests per minute
  (a burst of 120), 3,000 per hour, 16 concurrent (the owner can ask for more in
  the console's Limits page). See **Rate limits** below for what counts.
- **Poll** `GET {poll.url}` with the key every `interval_s`, for a bounded
  time, then fall back to checking once per search:

```
{"agent_id": "…", "status": "unclaimed" | "claimed" | "expired", "scopes": ["credits", "mining"],
 "claim_expires_at": "…", "claimed_at": "…",
 "project_id": "…", "console_url": "https://platform.nyks.dev/projects/…",
 "mining": {"available": true, "slots": ["twilight-slot-3"],
            "participant_has_other_agent": false,
            "last_enrollment": {"slot": "…", "minted_at": "…"}}}
```

  `404` means the key does not belong to that agent (or the agent is gone).
  `"expired"` means it was never claimed in time: stop, and register again.
  Once `"claimed"`, `project_id` and `console_url` appear: `console_url` is
  this agent's project page on the console. Print it whenever you send a human
  to the console (the mining re-approval below) — never the `/claim` page,
  whose code was consumed by the claim and would fail. Both are absent while
  unclaimed.
- **Lost your record?** `GET https://agents-v1.nyks.dev/v1/agents/me` with the
  key alone returns the same body (including `agent_id`). If your local
  `agent.json` is gone or unreadable but the `sr-` key on disk is intact, this
  rebuilds the record unattended — no re-claim, no human step. Same `404` for an
  unknown or revoked key.
- **Lost the claim link too (still unclaimed)?** `POST
  https://agents-v1.nyks.dev/v1/agents/{agent_id}/claim-code` with the key mints a
  **fresh** `claim_url`/`claim_code` (the old one dies) → `{"claim_url": "…",
  "claim_code": "…", "claim_expires_at": "…"}`. The original code is never
  re-served (only its hash is stored), so this is the only way back to a working
  human link. Show the human the new `claim_url`. Unclaimed agents only (a claimed
  one has nothing to claim → `404`); the fresh code is short-lived (~1h).
- **Mining, once claimed with the `mining` scope:**

```
POST https://agents-v1.nyks.dev/v1/agents/enroll
{"slot": "twilight-slot-3"}
→ {"token": "…", "slot": "twilight-slot-3", "expires_at": "…"}
```

  The token is a single-use, 15-minute enrollment token identical to the one
  the portal's Mining page mints; redeem it with the MINIS server at once. The
  scope in the poll response is the instruction: claimed with `mining` and no
  enrollment yet means enroll now, no second flag needed. But if
  `participant_has_other_agent` is `true`, your user already runs a mining
  agent: the participant earns one share no matter how many of their agents
  enroll, and within an epoch only one submits, so leave mining off for this
  one unless it is meant to replace the other.
- **Mining not granted at the claim** (poll `scopes` has no `mining`, or `enroll`
  returns `403 mining_not_granted`): you cannot add it yourself — granting a
  scope is the user's decision, never the key's. Ask the human to open the poll
  response's `console_url` (this agent's project page — one click, already signed
  in) and press **Grant mining**; after that one re-approval, the scope shows up
  on your next poll and `enroll` works. Do not send them to the `/claim` page —
  the claim code is spent, so it only errors on the way to the same console.
  Nothing else about the agent changes, and search is unaffected.

Refusals on these routes are specific: `429` with `Retry-After` when the
registration limits trip (6 per minute per IP), `503` when registration is off
(`agents_disabled`) or the unclaimed pool is full (`capacity`, honor
`Retry-After`), `403 not_claimed`, `403 mining_not_granted`,
`409 attribution_unavailable`, `400 unknown_slot`, `501 minis_disabled`. A bad
key on `/v1/agents/enroll` gets a mute `401`, like the search API.

## Search

```
POST https://router-api.nyks.dev/v1/search
{"query": "…", "tier": "balanced"}
```

- `tier`: `fast` (three fast indexes, one per index group; the only tier an unclaimed
  agent may use), `balanced` (default, full fan-out with ranking), `max`
  (everything, including slow deep-research arms that may land after the
  response).
- Optional `providers: ["…"]` restricts the fan-out arms (on `balanced` or
  `max`; refused on `fast`). The core indexes, which run on every `balanced`
  and `max` request, are `exa`, `telem`, `brave`, `serper`, `parallel-search`,
  `perplexity-search` and `tinyfish`. `serpapi` (a Google read, slow lane) and
  the research arms (`parallel`, `tavily-research`, `pplx-agent`, `octen`)
  are extended: they fire only when named here, and a slow-lane arm lands on
  the trace after the response. On the hosted service the roster is the seven
  core indexes plus `serpapi`; the research arms exist only on self-hosted
  direct-key deployments, and naming one on the hosted service is a `400`.
  An unknown name is a `400`, never a silent omission.
  Example: `{"query": "…", "tier": "balanced",
  "providers": ["exa", "brave", "parallel-search"]}`.
- Optional `session_id` (also the `X-Session-Id` header) threads consecutive
  queries for the gateway's live reformulation tracker. It is **grouping /
  labeling only** — no context carried between queries, no caching, no billing
  effect; a reformulation of a recent query is detected as a free training
  signal, and the response echoes a `session` block (`id`, `query_index`,
  `reformulation_of`). It is **not** what builds the console's Threads view —
  that reads `trace.session_id` (below). Put the same id in both to get
  both.
- Optional `max_results` caps results per provider — default 8, negative is a
  `400`, and it is clamped to at most 25.
- Optional `recency`: `day` | `week` | `month` | `year`. Sent to every arm that
  takes it, and the router drops any result published before the window itself
  (an undated result is kept).
- Optional `domain_filter: ["…"]` restricts to up to 16 bare hostnames (no
  scheme, path, or spaces): sent to every arm that takes it, and the router
  drops any result whose host is not one of them or a subdomain of one.
- With either filter the response carries `filters_applied`: which arms were
  sent each filter (`sent_to`, `not_sent`) and how many results the router's
  check removed (`dropped`). Every response's `decision.build` names the build
  (`version`) and the tier configuration (`config_hash`) that answered.
- Optional `policy` is a plain-text routing hint (e.g. "prefer academic
  sources"); recorded only, it does not change routing.
- Optional `trace: {…}` is your own lineage envelope — see **Thread your
  searches into a trajectory** below. Stored on the trace, never sent to a
  provider, and nothing in it changes how the search runs. A malformed or
  oversized one (> 64 KiB, or not a JSON object) is dropped, never a reason the
  search fails. If a router does not support it you get `400` with
  `code: "trace_unsupported"` (see Errors) — the one case worth retrying once
  without the field.
- Optional `view: "merged"` adds `merged[]` (one entry per page across every
  usable arm, best first, with `result_id`, `source_id` (the same page in any
  search has the same one), `best_rank`, `found_by[]`, `groups` and every
  provider's text joined in `excerpt`), `indexes` (how each arm
  ended, with a typed reason) and `coverage` (what the union found that no
  single index did) to the response, and changes nothing else. `merged_limit`
  (default 12) and `max_chars` set the window and the excerpt budget, and
  `page_chars` (200..4000) the per-page cut — in order: the request's `page_chars`,
  the deployment's `[presentation.page_chars]` for your tier when it sets one,
  2,000 on `max`, else 1,200; all need the view, and `page_chars` does not
  combine with `excerpt_mode`. Any other `view` value is a `400`.
- Optional `objective` (what you are trying to find out) and `search_queries`
  (up to four reformulations) run in the same call; a page only a
  reformulation found says so in `found_by[].form`, and
  `coverage.reformulation_only` counts them. Each form is billed as one
  query per arm.
- **Retrying safely.** A retry of a search that already ran is a second fan-out
  and a second bill. Send an `Idempotency-Key` header (1–255 printable ASCII
  characters, unique per logical request; a UUID is the usual shape) on
  `POST /v1/search`: the same key with the same body is answered with the first
  run's response — same `request_id`, `Idempotent-Replayed: true` — and is not
  run or billed again. The same key while the first is still running is
  `409 idempotency_in_progress` (wait a second, send it again); the same key
  with a different body is `422 idempotency_key_reused` — a bug in your key
  generation, final. Keys live 24 hours, per project; the stream path ignores
  the header.
- The response carries `request_id` (also the `X-Request-Id` header, sent
  before any body bytes; a UUIDv7 — keep it, it is what verification and
  support key on), ranked `candidates[]` with per-arm `status`, `citations[]`,
  `cost_micros` and `cost_source` (`"reported"` means the provider's own
  number), `chosen` (index into candidates), `decision` (tier, providers,
  `trimmed[]` when the cost estimate exceeded the tier's ceiling and arms were
  dropped rather than the request refused), and `usage.cost_micros` in
  integer micro-dollars (1_000_000 = $1).
- `usage.pending > 0` means a slow-lane arm is still running: it was
  dispatched with the request, not waited for, and it lands on the trace
  afterwards, billed under the same request id, so `usage.cost_micros` is a
  floor until it does. On the hosted service that arm is `serpapi`, a Google
  read that lands within its 20 s timeout. On a direct-key deployment the
  research arms (`parallel`, `tavily-research`, `pplx-agent`, `octen`) take
  about 30 s to 4 min and their thinking is readable mid-run as
  `live_thinking`.
- Bodies over 64 KiB and queries over 4096 bytes are refused with `400`
  before dispatch. Unknown routes are `404`.

For token-by-token progress use `POST /v1/search/stream` (same body): NDJSON
events `start`, `agent` (live research thinking), `candidate`, `done` (the
ranked answer), then `complete` when late arms have landed. `done` is the
answer; `complete` is the ledger closing.

### Thread your searches into a trajectory

Send the same `trace.session_id` on every search of one conversation and the
console reads them back as a **session trajectory**: what you said before each
search, what you asked, what came back — in order, each once — with the turns,
context compactions, reformulations and any calls that left no trace marked.
The project's owner sees it under **Threads** in the console (one search is
under **Searches**); the operator console shows it too. The shape (all keys
optional except `session_id`):

```json
{"query": "…", "tier": "balanced",
 "trace": {
   "v": 1,
   "harness": "my-agent",
   "session_id": "9f1c0a5b7d3e4f60a1b2c3d4e5f60718",
   "turn_id":    "turn-0042",
   "call_id":    "call-0007",
   "window":     "none",
   "seq":        7,
   "history": [{"role": "assistant", "text": "Let me look up who founded Hetzner."}]
 }}
```

- `session_id` — one id per conversation, **1–128 characters from
  `[A-Za-z0-9_-]`**, sent as a JSON string. Anything else is stored but not
  threaded. It is compared within your project only, so it need not be
  globally unique; a hash of your host's session id is fine.
- `parent_session_id` — **only if you are a subagent**: the `session_id` of
  the session that started you, same shape. Keep your own `session_id`, and use
  the parent's `turn_id` for the turn you were started in. The console then
  lists you under the parent as its subagent — even when the parent made no
  search of its own in that turn — and labels the two `parent` / `subagent`.
  Without it, sessions that searched in the same `turn_id` are still shown
  side by side, just unlabelled. A value that is not id-shaped is ignored.
- `turn_id` — one id per user prompt; groups the searches of a turn.
- `call_id` — one id per search tool call.
- `window` — your context-compaction generation (`"none"`, then `"1"`, `"2"`,
  …); a change is shown as a compaction marker.
- `seq` — a counter kept by your process, bumped on every call. Skipped numbers
  that no lane accounts for are shown as calls that left no record (a refused
  or dropped request).
- `history` — one entry: the assistant's text just before this search, up to
  32 KiB. It is the only conversation content the envelope carries. The user's
  message and your final answer are not in it — see the next section.

What is kept: the ids, `window` and `seq` for every project; `history` only
when the project keeps search content (the owner's claim-time choice). A
metadata-only project still gets its trajectories — structure, timing, cost,
arms — with no text. There, an id is kept only if it looks like one: a string
of at most 128 bytes with no spaces (a sentence sent as a `turn_id` is treated
as content and dropped). Trajectories are read in the console; there is no
`/v1` endpoint that reads them yet.

### Say how the turn ended (optional, opt-in)

The envelope carries what you wrote *before* each search. What the user asked
and what you concluded are written when no search is being made, so the
console cannot show them unless you report them. If — and only if — your
operator has opted in, send one request when a turn **that searched** ends:

```
POST https://router-api.nyks.dev/v1/turns
{"session_id": "9f1c0a5b7d3e4f60a1b2c3d4e5f60718", "turn_id": "turn-0042",
 "status": "completed",
 "user_text": "Who founded Hetzner, and when?",
 "final_text": "Hetzner Online was founded by Martin Hetzner in 1997.",
 "model": "…", "usage": {"input_tokens": 18200, "output_tokens": 940},
 "steps": [{"kind": "assistant", "text": "Let me look that up."},
           {"kind": "tool", "name": "Read", "ms": 210, "ok": true},
           {"kind": "search", "call_id": "call-0007", "ms": 5900, "ok": true}]}
```

- `session_id` and `turn_id` are required and must be **the ones the turn's
  searches carried in `trace`** — that is the only join. Add
  `parent_session_id` if you are a subagent.
- `status`: `completed` (default), `interrupted` or `failed`. Only a completed
  turn keeps `final_text`.
- `final_text` and `user_text`: at most 64 KiB each, UTF-8. **They are stored
  as you send them** — remove credentials and anything the user would not want
  kept before sending, and cap long text yourself (`final_chars`, `truncated`,
  `user_chars`, `user_truncated` say what you cut).
- `steps` (at most 400, 256 KiB of text between them) is the turn in order:
  `assistant` (text you wrote), `tool` (a tool you called — **its name, how
  long it took, whether it worked, and nothing else**; never send a tool's
  input or output, and text on a tool step is discarded), `search` (where a
  search through this router fell, by the `call_id` its envelope carried).
- The reply is `202 {"accepted": true, "stored": …, "content_stored": …}`.
  `stored: false` comes with a `reason`: `no_search_in_session` (nothing was
  searched under that `session_id` with this key's project, so there is no
  thread to attach it to) or `no_store`. **Send it once and do not retry** —
  not on a `202` that was not stored, not on a `4xx`, not on a `429`.
- It costs nothing and works for an account that is out of credit. It does not
  count against your search rate; it has its own limit (your request limits,
  and never less than 30 a minute and 120 an hour). The body may be up to
  512 KiB (`413 body_too_large` beyond). Fields the router does not know are
  ignored.
- A metadata-only project keeps the status and the turn's skeleton — step
  kinds, tool names, timings, model, tokens — and none of the text.

## Reading a page in full

A search gives you about 1,200 characters per page. When the fact you need —
a parameter, a limit, a signature, a version — is on a page but not in its
excerpt, fetch that page:

```
POST /v1/fetch
{"pages": [{"source_id": "s_…"}], "max_chars": 10000}
```

- Fetch the **1–3 pages** you actually need, not every result. It is for
  specific facts; it did not help on list-style questions. Up to 5 per call.
- Name a page by the `source_id` from a merged view, or by a `url` a search
  returned. Only pages **your own searches returned in the last 24 hours**
  can be fetched; anything else is `not_recent` (free). For a URL Jevlin did
  not return, use your host's own fetch tool instead.
- Each page comes back as at most `max_chars` characters (default 10,000).
  If `next_offset` is not `null` and you need more, send the same page with
  `"offset": next_offset`; reading on within 15 minutes costs nothing extra.
  Optional `"query": "what you are looking for"` starts the first slice at the
  matching paragraph (experimental).
- When a page is much longer than one slice (`content_length` well above
  `max_chars`, or `truncated`), pass a `query` describing the fact you need
  so the slice starts near it, rather than paging from the top.
- A very long page may still be cut: then it says `"truncated": true` with
  its real `full_length`, `content_length` is how much of it you can read,
  `next_offset` turns `null` there, and `query` only searches that part. If
  what you need is further in, fetch it with your host's own tool.
- Links are removed from fetched text (a link keeps its words, loses its
  target) and markdown escapes are undone (`ZFM\_IMPORT\_SRV` reads
  `ZFM_IMPORT_SRV`); code blocks and inline code are kept as they are. To
  follow a link, use your host's own fetch tool.
- A `failed` page says why in `reason`. `"unrendered"` means the site sent
  an app shell ("Loading application…") instead of the page: it needs a
  browser, so asking again will not help — use another source.
- Each `ok` page costs 2,000 µ$; `failed`, `not_recent` and `invalid` pages
  are free. Fetching a page records it as used — no `result.fetched` feedback
  needed for it.
- Needs a claimed account that keeps content: `403 fetch_unavailable`
  otherwise. If the fetch service does not answer, the call still returns
  `200` and the pages it could not get are `failed` with
  `"reason": "upstream_failed"` (free); pages it already had come back as
  usual. Retry a failed page once.

## Rate limits — what counts

Limits are per project (every key of a project shares them): requests per
minute, requests per hour, and concurrent requests — 120 (burst 120) / 3,000 / 16
once claimed, 10 / 120 / 2 unclaimed, unless the operator has raised them. A
key may carry tighter limits of its own, nested inside the project's.

- **Every call to the search API counts**, not only searches: `/v1/feedback`,
  `/v1/judge`, and reads — `GET /v1/traces`, `GET /v1/traces/{id}`,
  `/v1/limits`, `/v1/account`. Reading a trace costs no money, but it takes
  one of the requests your next search needs. Read a trace when you have a
  reason to; never poll.
- `POST /v1/turns` is the exception: its own limit, above. `GET /healthz` is
  public and counted against nothing.
- Over a limit you get `429` with `Retry-After`. When the hourly limit is the
  one you hit, `Retry-After` can be minutes — wait it out, do not spin.
- **Pace yourself before the `429`.** Every search, stream and turn-end
  response says where your two windows stand after it was charged, in the
  `X-RateLimit-*` headers below; `Remaining` at `0` is usable again before
  `Reset`, since the buckets refill continuously. A `429` carries
  `Retry-After` and none of the window headers.

<!-- gencontract:begin headers -->

| header | on | meaning |
|---|---|---|
| `X-Request-Id` | `POST /v1/search` 200, `POST /v1/search/stream` 200, `POST /v1/fetch` 200 | UUIDv7; the trace id, equal to the body's `request_id`. Sent before any body bytes. |
| `X-RateLimit-Limit` | `POST /v1/search` 200, `POST /v1/search/stream` 200, `POST /v1/turns` 202 | The per-minute bucket's size (`burst`). Absent when the tenant has no per-minute limit. |
| `X-RateLimit-Remaining` | `POST /v1/search` 200, `POST /v1/search/stream` 200, `POST /v1/turns` 202 | Whole tokens left in the per-minute bucket after this request was charged. The bucket refills continuously, so 0 is usable again before `X-RateLimit-Reset`. |
| `X-RateLimit-Reset` | `POST /v1/search` 200, `POST /v1/search/stream` 200, `POST /v1/turns` 202 | Unix seconds at which the per-minute bucket is full again (not when the next token arrives — a 429's `Retry-After` says that). |
| `X-RateLimit-Limit-Hour` | `POST /v1/search` 200, `POST /v1/search/stream` 200, `POST /v1/turns` 202 | The per-hour bucket's size (`requests_per_hour`). Absent when the tenant has no hourly limit. |
| `X-RateLimit-Remaining-Hour` | `POST /v1/search` 200, `POST /v1/search/stream` 200, `POST /v1/turns` 202 | Whole tokens left in the per-hour bucket after this request was charged. |
| `X-RateLimit-Reset-Hour` | `POST /v1/search` 200, `POST /v1/search/stream` 200, `POST /v1/turns` 202 | Unix seconds at which the per-hour bucket is full again. |
| `Idempotent-Replayed` | `POST /v1/search` 200 | Present (and `true`) only when this response was replayed from the store behind `Idempotency-Key`: the first run's response, byte for byte, nothing ran and nothing was billed. Reference §6.4. |
| `Retry-After` | `POST /v1/search` 409, `POST /v1/search` 429, `POST /v1/search` 503, `POST /v1/search/stream` 429, `POST /v1/search/stream` 503, `GET /v1/traces` 429, `POST /v1/fetch` 429, `POST /v1/turns` 429, `GET /v1/account` 429, `GET /v1/account` 503 | Seconds to wait before sending the same request again. On a 429 it is exactly when the next token is back (minutes when the hourly bucket ran out); `1` on `409 idempotency_in_progress`, `503 overloaded` and `429 concurrency`. The two 402s and `503 paused` carry none. |

<!-- gencontract:end headers -->

## Account

```
GET /v1/account   — your key, your windows, your spend today / this week / this month, your caps, in one read
```

What the gateway knows about the calling key and its project: the key's `id`,
`label`, `scopes` and `expires_at`; `rate` (the configured limits and both
windows as they stand — the same numbers as the headers above, without making
a search); `spend` (below); `caps` (the self-set limits below); `retention`;
`tiers_allowed` and `default_tier`; and `console_url`, the human's address for
money. Spend-free and exempt from the `402`s; counted like any other read.

`spend` is today against the enforced daily ceiling — `daily_ceiling_micros`,
`spent_today_micros`, `remaining_today_micros`, `credits_exhausted` — plus the
same counters over the calendar-UTC week (from Monday) and month (from the
1st): `spent_week_micros`, `spent_month_micros`, and `remaining_week_micros` /
`remaining_month_micros` **only when that window has a self-set cap** (the
distance to the cap, floored at 0). `day_resets_at`, `week_resets_at` and
`month_resets_at` are when each window starts over. The credit balance itself
is **not** here — the gateway does not hold it, and an uncapped week or month is
bounded by that balance alone, which is why its `remaining_*` is absent rather
than a number that would read as "nothing left". Show
`spend.remaining_today_micros`, which is what will actually be refused next,
and send the human to `console_url` for the balance.

## Traces

```
GET /v1/traces          — recent requests (your tenant only)
GET /v1/traces/{id}     — one trace: arms, statuses, costs, agent thinking
```

Debug with the trace, not by re-asking: a failed or expensive request is
explained by its own trace. Unknown trace ids return 404. Traces persist
30 days. A read is free of charge but is a request like any other against
your rate limit (above). Whether a trace keeps *content* — your query, the answer, and any
`trace` envelope you sent — depends on the owner's claim-time choice ("Keep
search content", on by default); with it off, and always for unclaimed agents,
the trace keeps costs, providers, and timings but no text. Metadata (costs,
providers, timings, and the trace's structural lineage) is retained either way —
which is why a trajectory exists for every project, with or without its text.

**Spend analytics.** The console's Reports page (per project) shows *providers
by share of spend* — every arm sized and grouped by category (retrieval /
answer / research), plus period-over-period deltas — so the human who owns the
key can see where the fan-out spends. There is no agent API for it; point them
at the console.

## Feedback (optional, cheap, valuable)

```
POST /v1/feedback
{"trace_id": "…", "events": [{"type": "result.cited", "url": "https://…"}]}
```

Closed event vocabulary: `result.fetched`, `result.cited`,
`result.rejected` (these three need a `url`), `query.reformulated`,
`session.abandoned`, `answer.accepted`, `judge.agree`, `judge.disagree`.
Send `result.cited` for sources you actually used in an answer — it trains
the ranker.

`trace_id` must be one of **your own** traces, still inside the gateway's
retention window (the same lookup as `GET /v1/traces/{id}`). `202` means
taken; `400` is a bad body or an unknown event type; `404` is an unknown,
expired, or someone else's trace — nothing is recorded. Send feedback when you
have it; do not hold it for later and do not retry a `404`.

`POST /v1/judge {"trace_id": "…"}` is experimental: it asks an LLM judge to score a
trace's candidates, and the judge is not calibrated yet; full support will follow (needs the operator to have enabled judging; `503 judge_disabled`
otherwise, and `403 not_claimed` for an unclaimed agent). It is an LLM call —
expect 10–30 s.

## Spend limits (set your own budget)

```
GET  /v1/limits   — your caps + your spend: today against the ceiling, the week and month against their caps
POST /v1/limits   — set caps: {"daily_micros":…, "weekly_micros":…, "monthly_micros":…}
```

Cap what this key's project may spend, on top of the credit balance. Micro-dollars
throughout (`1_000_000 = $1`); `0` — or an omitted field on `POST` — means *no
cap* on that window. Windows are calendar UTC: day from 00:00, week from Monday,
month from the 1st. `GET` returns the three caps plus `today` `{spent_micros,
ceiling_micros, remaining_micros}`, where `ceiling_micros` is the effective daily
ceiling the gateway is enforcing — the lowest of the credit balance and your caps
— and `week` and `month`, each `{spent_micros, cap_micros, remaining_micros}` on
the same counters, with `remaining_micros` present only when that window has a
cap (an uncapped window is bounded by the balance, which is not here).
A cap set through this endpoint binds on the control plane's next sweep, up to
a minute later (the console's Limits tab asks for a sweep straight away, so a
cap set there binds within seconds). `POST` is a partial update: send only the
windows you want to change, and it needs the `limits` scope (or an unscoped
key). Reading or setting limits spends nothing and works even when the
account is at its ceiling or out of credit — so you can always raise your own budget.

Example — a $5/day and $50/month cap:
```
curl -s https://router-api.nyks.dev/v1/limits \
  -H "Authorization: Bearer sr-…" -H "Content-Type: application/json" \
  -d '{"daily_micros": 5000000, "monthly_micros": 50000000}'
```

When a self-set cap is reached you get `402 spend_ceiling` (below); raise or clear
the cap, or wait for the window to roll over at UTC midnight.

## Errors — what to do, precisely

Every error carries a machine `code` at the **top level** — read it the same way
on all three hosts. On the search API `error` is a string
(`{"error": "…", "code": "…"}`); on the agents host and the console API `error`
is an object (`{"error": {"code","message"}, "code": "…"}`) with the same `code`
also mirrored top-level. Branch on the top-level `code`, never on the message. An
unrecognised code — and a `4xx` with no code — is final: do not retry it. The
**only** code worth a retry-without-a-field is `trace_unsupported`; the `429`s,
the `503`s and `idempotency_in_progress` invite the same request again after
`Retry-After`.

- **`trace_unsupported`** (`400`): the router rejected the body because of
  the `trace` field. Retry the same search once with `trace` removed; treat
  every other `4xx` as final (this is what stops a blind retry from double-posting
  on an unrelated validation error). This platform supports `trace`, so you will
  not see it here — it is for older or trace-disabled routers.
- **401** (`missing_key` / `bad_key`): the key is wrong, revoked, or expired
  unclaimed. Stop. Do not retry; tell the human (or, for an agent-registered key,
  poll the agent route: `"expired"` means register again).
- **403 `scope`**: the key is scoped and this route is outside its scopes (the
  message names the one missing). Final — a different key, or the owner widens
  this one.
- **402 `credits_exhausted`**: the account is out of credit. Stop retrying —
  this will not fix itself. Tell the human to top up at
  https://platform.nyks.dev. (Trace, limits and account reads still work on
  402, so you can still show what was spent.)
- **402 `spend_ceiling`**: today's spend cap is hit — either a self-set
  daily/weekly/monthly limit (raise or clear it via `POST /v1/limits`, see Spend
  limits) or, for an unclaimed agent, the $0.10 tier ceiling. It resets at UTC
  midnight (or the week/month boundary), when the cap is raised, or when a human
  claims the agent.
- **402 `budget_exceeded`**: no provider set fits this tier's per-query cost
  ceiling. Final — lower the request (a cheaper `tier`, or a smaller
  `providers`), do not retry as-is.
- **400 `invalid_tier` / `invalid_query` / `invalid_request` / `malformed_json`**:
  the request is wrong (bad or not-allowed tier; empty-or-too-long query or a bad
  `domain_filter`; an unknown provider name, a bad `view` or `Idempotency-Key`;
  or a body that is not valid JSON). Final — fix the request, do not retry.
- **409 `idempotency_in_progress`**: the first request under this
  `Idempotency-Key` is still running — wait `Retry-After` (1 s), send the same
  request again. **422 `idempotency_key_reused`**: same key, different body.
  Final.
- **429 (`rate_limited` / `concurrency`)**: rate or concurrency limit. Honor the
  `Retry-After` header — it may be minutes when the hourly limit ran out —
  then retry once. Exception: never retry a `POST /v1/turns`.
- **413 `body_too_large`** on `POST /v1/turns`: the body is over 512 KiB. Final.
- **`POST /v1/fetch`**: **403 `fetch_unavailable`** — this account cannot
  fetch (unclaimed, zero-retention, or no credit to bill); final. **503
  `fetch_disabled`** — not offered on this deployment; final. A fetch the
  service could not answer is not an error code: it is a `200` whose
  pages say `"reason": "upstream_failed"`.
- **503 `paused` / `overloaded`**: the platform is paused for everyone, or the
  gateway is at its in-flight limit (`Retry-After: 1`). Retry after
  `Retry-After`, then back off. `503 not_ready` is `GET /healthz` while the
  gateway starts or drains.
- **5xx** otherwise: retry once with backoff; then surface the error.

Never loop on 401/402. One request = real money (micro-dollar costs are in
every response); prefer `tier: "fast"` for routine lookups.
