# search-router server API — for Twilight MINIS

How a MINIS Slot server gets user data out of search-router: which URLs to
call, what to send, what comes back, with real examples. Fetchable without a
login at `https://platform.nyks.dev/minis-api.md`.

Base facts:

| | |
|---|---|
| Issuer (`iss` on every JWT we sign) | `https://platform.nyks.dev` |
| Signing algorithm | EdDSA (Ed25519) — pin it; never trust the token header |
| Your credential | one `client_id` per Slot (e.g. `minis-slot-3`), scope `verification:read` |
| User identity | `user_id`: bare lowercase UUID, stable across all of a user's tenants and keys |

The `user_id` reaches you in exactly two places, and they are the same string:
the enrollment token's `sub` claim (once, at enrollment) and the verification
API's `user_id` field (continuously).

---

## 1. GET /.well-known/jwks.json — our public keys

```
GET https://platform.nyks.dev/.well-known/jwks.json
```

Response (`200`, `Cache-Control: max-age=300`):

```json
{"keys":[{"kty":"OKP","crv":"Ed25519",
  "x":"JJ156dBdNd1bgzBASJtE9xJs0jxjDlhAhBMEt1bQOi0",
  "kid":"747eb17c7478febe","alg":"EdDSA","use":"sig"}]}
```

During a key rotation the old and new keys are published together, so a cached
copy never strands tokens. Fetch-on-unknown-`kid` is safe against us.

## 2. The enrollment token — where `user_id` comes from

A user who opts into Twilight mining on this portal gets a compact JWS to paste
into `tokendrop-proxy enroll`. Header and claims, exactly (no email, no name,
no profile data — ever):

```json
{"alg":"EdDSA","typ":"enroll+jwt","kid":"747eb17c7478febe"}
{
  "iss": "https://platform.nyks.dev",
  "sub": "0321a11e-1a31-4d98-b51f-3faf6f838d3e",
  "aud": "twilight-slot-3",
  "exp": 1787662213,
  "iat": 1787661313,
  "jti": "a6327a8c8352c0d25b1f0b02e9a448e2"
}
```

Validation on your side: pin EdDSA at parse time, resolve `kid` against the
JWKS, check `iss` and `aud`, check `exp` (15-minute lifetime), enforce
single-use on `jti`, then read `sub` — that is the `user_id` you bind the
participant to.

Note `typ: enroll+jwt`. Our access tokens are `typ: at+jwt`, and the
verification endpoint refuses any other type — a leaked enrollment token
cannot authenticate to the API below.

## 2a. A second issuance path: the claimed agent (2026-09-09)

The same token now has two senders. The session path is unchanged: the portal's Mining
page mints from a signed-in browser and `sub` is the session's user id. The agent path is
new: a headless agent that registered itself (`POST /v1/agents/register`, no credential)
and was then claimed by a person on the portal calls, with its own bearer `sr-` key and no
browser:

```
curl -X POST https://agents-v1.nyks.dev/v1/agents/enroll \
  -H "Authorization: Bearer sr-…" -H "Content-Type: application/json" \
  -d '{"slot": "twilight-slot-3"}'

{"token": "eyJhbGciOiJFZERTQSIs…", "slot": "twilight-slot-3", "expires_at": "…"}
```

`sub` is the claiming user, never a value the request supplies (a body naming one is
rejected). The route refuses unless the agent is claimed (`403 not_claimed`), the key
carries the `mining` scope granted at the claim (`403 mining_not_granted`), and the claimer
resolves to a real user (`409 attribution_unavailable`); `400 unknown_slot` and
`501 minis_disabled` as on the portal path. Both paths call one signer with one
claim-construction function, so what you receive is byte-for-byte the same token shape from
the same key: same issuer, `typ`, claim set, 15-minute life and JWKS. A second sender, not a
change to the message.

Attribution edge: a self-registered agent that no human has claimed yet searches on the
unclaimed tier, and those requests verify as `SERVED` with `user_id` omitted until the
claim — the same fail-safe as operator tenants and multi-owner organizations.

## 3. POST /oauth/token — get an access token

```
POST https://platform.nyks.dev/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=minis-slot-3&client_secret=<secret>
```

(HTTP Basic auth is accepted instead of the form fields.)

Response (`200`):

```json
{"access_token":"eyJhbGciOiJFZERTQSIs…","token_type":"Bearer",
 "expires_in":3600,"scope":"verification:read"}
```

Errors (RFC 6749 shape): `400 unsupported_grant_type` / `invalid_scope`,
`401 invalid_client` (unknown, disabled, and wrong-secret are deliberately
indistinguishable), `429 slow_down` with `Retry-After` when the exchange is
paced (30/min per address, 300/min platform-wide — a slot server needs one
exchange an hour, so only a loop ever sees it; wait and retry, never spin).
Tokens live 1 hour; refresh by repeating the exchange.
**A `401` anywhere is always "refresh and retry", never an answer about a
request.** Revoking a client takes effect on its next request, not at token
expiry.

## 4. POST /v1/verifications — resolve request ids to user data

The one data-retrieval call. Batched, read-only, side-effect free, safe to
retry.

```
POST https://platform.nyks.dev/v1/verifications
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request body:

```json
{
  "client_batch_id": "vfy_01J8XM4Q7B",
  "request_ids": [
    "01a038ea-2356-7fd5-983c-ffa6745559d5",
    "req_that_never_existed"
  ]
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| `request_ids` | `string[]` | yes | 1–500 per call. The values from the `X-Request-Id` header on `POST /v1/search` responses. |
| `client_batch_id` | `string` | no | Opaque to us; echoed back. |

Response — always `200` when the batch was processed, exactly one result per
requested id, positionally (duplicates answered per occurrence). A real
production response:

```json
{"client_batch_id":"vfy_01J8XM4Q7B","results":[
  {"request_id":"01a038ea-2356-7fd5-983c-ffa6745559d5",
   "status":"SERVED",
   "user_id":"0321a11e-1a31-4d98-b51f-3faf6f838d3e",
   "served_at":"2026-08-25T12:34:29.000Z",
   "usage":{"cost_micros":5000},
   "decision":{"tier":"fast","providers":["openai","exa"]},
   "candidates":[{"provider":"openai","status":"error"},
                 {"provider":"exa","status":"ok"}],
   "chosen":1},
  {"request_id":"req_that_never_existed","status":"NOT_FOUND"}
]}
```

### Output fields

| Field | Type | Present on | Notes |
|---|---|---|---|
| `status` | `string` | always | Closed set below. Unknown values: treat as retryable. |
| `user_id` | `string` | `SERVED`, `FAILED` | Bare lowercase UUID; same string as the enrollment `sub`. Omitted when the request is not attributable to a person — your binding compare then fails safe. |
| `served_at` | `string` | `SERVED`, `FAILED` | RFC 3339, UTC, millisecond precision, from our clock. |
| `usage.cost_micros` | `int64` | `SERVED` | Micro-dollars, summed from the billing ledger at query time (slow-lane spend included, so re-verifying later can show more). |
| `decision.tier` | `string` | `SERVED`, `FAILED` | Optional. |
| `decision.providers` | `string[]` | `SERVED`, `FAILED` | **The normative array.** `provider_set` does not exist in this schema. |
| `candidates[].provider` / `.status` | `string` | `SERVED`, `FAILED` | Per-arm outcome. |
| `chosen` | `int` | `SERVED`, `FAILED` — **always** | Index into `candidates`; `-1` = none chosen. Never absent. |
| `failure_reason` | `string` | `FAILED` | Short stable token, e.g. `no_usable_candidate`. |

No content-bearing field exists in this schema — no answer text, citations,
query text, URLs, or snippets, structurally.

### Status vocabulary

Three conclusive, three retryable. Every ambiguous condition resolves to a
retryable status.

| `status` | Conclusive? | Meaning |
|---|---|---|
| `SERVED` | **yes** | Existed and was served; check `user_id`. |
| `FAILED` | **yes** | Existed and attributed, but not servable. No work done. |
| `NOT_FOUND` | **yes** | No such id within retention. Permanently rejects the work. |
| `NOT_YET_DURABLE` | no — retry | Id issued < 15 minutes ago, not yet queryable. |
| `EXPIRED` | no — retry, then age out | Older than the 30-day retention window. |
| `UNAVAILABLE` | no — retry | We could not answer for this id right now. |

### Transport (never verdicts)

`200` read per-item statuses · `400` malformed, fix and retry · `401` refresh
token and retry · `403` scope · `429` pacing (honor `Retry-After`) · `5xx` and
anything else retryable. **No HTTP status is ever an answer about a request.**

### Rate limits

60 requests/minute per client, burst 120. `X-RateLimit-Remaining` and
`X-RateLimit-Reset` (unix timestamp) on every response, including 429s.

Measured on production 2026-08-25 (300 consecutive full 500-id batches at
60 rpm over TLS, zero errors): p50 110 ms · p95 147 ms · p99 196 ms.

## 5. Operational terms

- Verification records: metadata only, retained **30 days**.
- Durability: queryable ≲ 60 s after the search response; an id younger than
  **15 minutes** that we cannot find returns `NOT_YET_DURABLE`, never
  `NOT_FOUND`.
- Account deletion or ban never purges verification records — a removed
  account cannot produce a false `NOT_FOUND` for work already done.
- Request ids are UUIDv7 in 36-character UUID form. Treat them as opaque.
- These domains sit behind Cloudflare: send a real `User-Agent`
  (e.g. `minis-slot-3/1.0`) from your workers — generic/absent UAs may be
  challenged.
