Endpoints
| Endpoint | What it does |
|---|---|
| search api https://api.jevlin.ai | |
| POST /v1/search | one query, ranked results, costs, trace id |
| POST /v1/search/stream | same, as NDJSON: start · agent · candidate · done · complete |
| POST /v1/fetch | the full text of up to five pages your own recent searches returned, in slices |
| GET /v1/traces | recent requests for your project |
| GET /v1/traces/{id} | one trace: arms, statuses, agent thinking |
| console › Threads | a client session's searches read as a path (from the trace envelope; no API yet) |
| POST /v1/turns | optional: how a turn ended — the question, the final answer, the steps (free, own rate limit) |
| POST /v1/feedback | usage signals (result.cited …) that train the ranker |
| POST /v1/judge | LLM judge scores for a trace (experimental: not calibrated yet) |
| GET /v1/limits | your spend caps + your spend today, this week and this month against them |
| POST /v1/limits | set daily / weekly / monthly spend caps |
| GET /v1/account | the calling key (scopes, expiry), rate windows, spend today / this week / this month with resets, retention |
| GET /healthz | public readiness probe, no key: {"ok": true} or 503 |
| agents https://agents-v1.jevlin.ai | |
| POST /v1/agents/register | an agent mints its own key, no credential |
| GET /v1/agents/{agent_id} | the agent polls: unclaimed · claimed · expired |
| GET /v1/agents/me | self-lookup by key: rebuild a lost record |
| POST /v1/agents/{agent_id}/claim-code | recover the claim link: mint a fresh code |
| POST /v1/agents/enroll | a claimed agent mints a mining enrollment token |
Send Authorization: Bearer sr-… on every call except registration. Every search or fetch response carries X-Request-Id before any body bytes; keep it. A search also names what answered it in decision.build: the gateway build (version) and the tier configuration (config_hash). The full machine-readable contract — every request field, response field, enum and error — is the OpenAPI spec.
Statuses worth knowing
| Status | Meaning |
|---|---|
| 200 | The answer, with usage.cost_micros (1,000,000 = $1). A floor while a pending slow-lane arm is still running. |
| 401 | missing_key / bad_key: bad, revoked, or expired-unclaimed key. Fix the key; do not retry. |
| 402 | credits_exhausted: the organization is out of credit; top up on the billing page. spend_ceiling: today's cap is hit (the $0.10 tier for an unclaimed agent). Trace reads keep working. |
| 403 | On /v1/agents/enroll: not_claimed or mining_not_granted. A human has to claim the agent with the mining scope, or grant it later with Grant mining on the project page — send them to the poll response’s console_url, not the claim page. On /v1/fetch: fetch_unavailable — this account cannot fetch (an unclaimed agent, a zero-retention account, or no credit to bill); final. |
| 429 | Rate limit; honor Retry-After. Registration allows 6 per minute per IP. |
| 503 | agents_disabled: self-registration is off. capacity: the unclaimed-agent pool is full; honor Retry-After and try again later. fetch_disabled: page fetch is not offered on this deployment. |
Every error is {"error": "…", "code": "…"} — branch on code, never the message. An unrecognised code, and any 4xx without one, is final. The one code worth a retry is trace_unsupported (400/422): the body was rejected for its trace field — retry once without it. This platform accepts trace, so you will not see it here; it exists for older or trace-disabled routers, so a client never double-posts on an unrelated validation error.
Onboard an agent without a human in the loop
An agent with no key registers itself and searches at once on a small unclaimed tier. One visit by a human to the claim link binds it into their organization; mining enrollment then runs unattended.
POST /v1/agents/registerwith an optionalname,clientandrequested_scopeshint. The response carries the key (shown once), a claim URL and code, and a poll URL.- Show the human the claim URL, or the code to type at /claim. They sign in here, see the agent's name and registration time, and grant
creditsand/ormining. The code is single use and expires with the unclaimed key. - Poll
GET /v1/agents/{agent_id}with the key untilstatusisclaimed.expiredmeans register again. Lost the record but still hold the key?GET /v1/agents/mereturns the same body (withagent_id) and rebuilds it. - Claimed with the mining scope:
POST /v1/agents/enrollwith a slot returns the same 15-minute enrollment token the Mining page mints, to redeem with the MINIS server at once.
| Dial | Unclaimed (defaults) | Claimed |
|---|---|---|
| Tiers | fast only | all |
| Daily spend ceiling | $0.10 | set from the organization's balance |
| Requests per minute / burst | 10 / 10 | 120 / 120 |
| Requests per hour | 120 | 3,000 |
| Concurrency | 2 | 16 |
| Trace retention | metadata only | the organization's posture |
| Credit | none | the organization's |
| Lifetime | 7 days, then the key stops working | until revoked |
curl -s -X POST https://agents-v1.jevlin.ai/v1/agents/register \
-H "Content-Type: application/json" -H "User-Agent: my-tool/1.0" \
-d '{"name": "my-agent", "client": "my-tool/1.0", "requested_scopes": ["mining"]}'
{"agent_id": "…", "key": "sr-…",
"claim_url": "https://platform.jevlin.ai/claim/AB12-CD34", "claim_code": "AB12-CD34",
"claim_expires_at": "…", "poll": {"url": "https://agents-v1.jevlin.ai/v1/agents/…", "interval_s": 5},
"tier": "unclaimed"}
curl -s https://agents-v1.jevlin.ai/v1/agents/<agent_id> -H "Authorization: Bearer sr-…" # or /v1/agents/me
{"agent_id": "…", "status": "claimed", "scopes": ["credits", "mining"], "claimed_at": "…",
"project_id": "…", "console_url": "https://platform.jevlin.ai/projects/…",
"mining": {"available": true, "slots": ["twilight-slot-3"], "participant_has_other_agent": false}}
curl -s -X POST https://agents-v1.jevlin.ai/v1/agents/enroll -H "Authorization: Bearer sr-…" \
-H "Content-Type: application/json" -d '{"slot": "twilight-slot-3"}'
{"token": "…", "slot": "twilight-slot-3", "expires_at": "…"}Refusals are specific: 403 not_claimed, 403 mining_not_granted, 409 attribution_unavailable, 400 unknown_slot, 501 minis_disabled. These hosts sit behind Cloudflare, so send a real User-Agent.
First request
curl -s https://api.jevlin.ai/v1/search \
-H "Authorization: Bearer $SEARCHROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "state of grid-scale battery storage 2026", "tier": "balanced"}'
# tiers: fast (three fast indexes, one per index group; the only tier for an unclaimed agent) · balanced (default)
# · max (15 results per arm, a 24-page merged window and 2,000-character excerpts; deep-research arms,
# where the deployment runs them, may keep working after the response — watch the trace)Request options
Every field a caller shapes a query with — only query is required. The complete request and response schema (every field, enum and error) is in the OpenAPI spec.
| Field | What it does |
|---|---|
| query | The search query. Required, non-empty, ≤ 4096 characters. |
| tier | fast · balanced (default) · max — how wide the fan-out is and the per-query cost ceiling. Unclaimed agents get fast only. |
| providers | A subset of the provider list below; omit to let the tier choose. Naming an extended arm also fires it; unknown names are ignored. |
| domain_filter | Up to 16 bare hostnames — no scheme or path, e.g. ["developer.mozilla.org"]. 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 — see filters_applied below. |
| recency | day · week · month · year. Sent to every arm that takes it, and the router drops any result published before the window (an undated result is kept) — see filters_applied below. |
| max_results | Results requested per provider. Default 8, clamped to [1, 25]. |
| session_id | Threads consecutive queries for the gateway's live reformulation tracker; the response echoes a session block. Grouping only — no context carried, no caching, no billing effect. It does not build the Threads view; trace.session_id does. |
| trace | Optional lineage envelope (≤ 64 KiB JSON object) that threads this search into a session: send the same trace.session_id (1–128 characters from [A-Za-z0-9_-]) on every search of a conversation, plus turn_id, window, seq and the assistant's last text in history if you have them, and the console reads the session back as a path under Threads. A subagent adds parent_session_id, the session that started it, and is drawn under it. Nothing in it changes how the search runs. Its ids are kept for every project and read at rest to build that view; its text only when the project retains content. The shape is in the skill file below. |
| view | "merged" adds merged[] (one entry per page across every usable arm, best first, with a source_id that names the page in any search, found_by[], groups, votes, every distinct text the arms gave for the page in excerpts[] and joined in excerpt, and the query's identifiers found on the page in matched_terms), indexes (how each arm ended) and coverage (what the union found that no single index did) to the response and changes nothing else. The window is 12 pages by default; merged_total says how many there were. |
| merged_limit | The merged window, 1–50 (default 12; 24 on max, and per search_queries form). Needs view: "merged". |
| max_chars | A total character budget for the returned pages' excerpts; excerpts_trimmed says whether anything was cut. Needs view: "merged". |
| page_chars | The per-page excerpt cap for this search, 200–4,000 characters, in place of the default (2,000 on max, else 1,200). With merged_limit it trades pages for depth at the same total: 20 pages × 600 characters instead of 10 × 1,200. Needs view: "merged" and does not combine with excerpt_mode. |
| objective | What you are trying to find out, in your own words. Forwarded to the providers that take one and recorded on the trace. It changes the order of the results only where a ranker profile is configured and rank asks for it; none is configured here. |
| rank | Beta experiment: auto (default) · none · llm · the name of a configured ranker profile. Everything but auto needs view: "merged"; the fast tier is never ranked. With no profile configured, results keep the default order and decision.rank.status says why. The spec has the profile kinds. |
| excerpt_mode | "split" (experimental): spends one excerpt budget on more, shorter pages. Needs view: "merged" and balanced or max. |
| merge_mode · excerpt_style · exclude | Experimental presentation controls, served only where the deployment turns them on (a 400 elsewhere): merge_mode: "interleave" orders pages round-robin across the arms, excerpt_style: "single" keeps one excerpt per page, and exclude drops up to 100 pages named by source_id or URL. The response echoes them in presentation. |
| search_queries | Up to four reformulations, run beside the query in the same call by every arm that batches. 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. |
domain_filter and recency are enforced by the router. Each is sent to every arm that takes it, and a result that fails the check is dropped before the merge, so a host outside your list or a page dated before your window does not come back. With either filter the response carries filters_applied: per filter, the arms it was sent to (sent_to), the arms it was not (not_sent) and how many results the router's own check removed (dropped).
Providers, tiers and lanes
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) is extended: it fires only when named in providers and lands on the trace after the response. exa-lite (a lighter Exa read) is extended too where the deployment runs it: one of the fast tier's three arms, and on balanced or max fired only when named. On this deployment, which routes through Telem, the research arms (parallel, tavily-research, pplx-agent, octen) are not configured, and naming one is a 400. Omit providers to let the tier choose the fan-out; pass a subset on balanced or max to restrict it (it is refused on fast).
curl -s https://api.jevlin.ai/v1/search \
-H "Authorization: Bearer $SEARCHROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "state of grid-scale battery storage 2026",
"tier": "balanced",
"providers": ["exa", "brave", "parallel-search"]}'- Core arms fire on every request; extended arms only when your
providerslist names them. - A slow-lane arm is dispatched with the request but not waited for: it returns
pending, lands on the trace afterwards and is billed under the same request id, sousage.cost_microsis a floor until it does. Here that arm isserpapi, which lands within 20 seconds. On a direct-key deployment the research arms (parallel,tavily-research,pplx-agent,octen) take about 30 seconds to 4 minutes; their thinking streams asagentevents and is readable mid-run aslive_thinking. - Cost estimates are checked before dispatch: a set over the tier's ceiling is trimmed (see
decision.trimmed), not refused. The daily ceiling refuses with 402. cost_source: "reported"meanscost_microsalready carries the provider's own number; ignore cost figures insideagent.raw.- Bodies over 64 KiB and queries over 4096 characters are refused with 400 before dispatch.
Reading a page in full
A search gives about 1,200 characters per page. When the fact you need — a parameter, a limit, a signature — is on a page but not in its excerpt, fetch that page: POST /v1/fetch returns the text of up to five pages per call, each named 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 comes back not_recent, free. It is not a general fetcher.
curl -s https://api.jevlin.ai/v1/fetch \
-H "Authorization: Bearer $SEARCHROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"pages": [{"source_id": "s_…"}], "max_chars": 10000, "query": "what you are looking for"}'
{"request_id": "…",
"pages": [{"source_id": "s_…", "url": "…", "title": "…", "status": "ok",
"content": "…", "offset": 0, "next_offset": 9874, "content_length": 171842,
"provider": "tavily", "cached": false, "cost_micros": 2000}],
"usage": {"cost_micros": 2000, "latency_ms": 812}}- Each page comes back as at most
max_charscharacters (default 10,000; 1,000–50,000) fromoffset.next_offsetis where to read on from,nullonce the page is exhausted; reading on within 15 minutes is served from the fetch cache. An optionalquerystarts the first slice at the paragraph that matches it best (experimental). - Links are removed from the text (a link keeps its words, loses its target) and markdown escapes are undone; code blocks are kept as they are. A page the upstream cut says
truncated: truewith its realfull_length. - Per page:
ok;failedwith areason(upstream_failed, worth one retry, orunrendered, an app shell that needs a browser — use another source);not_recent;invalid. The call is200whatever the pages say. - Each
okpage costs 2,000 µ$ ($0.002), against the same ceilings and rate windows as a search;failed,not_recentandinvalidpages are free. A fetched page is recorded asresult.fetchedon the trace of the search that returned it, so no feedback post is needed for it. - Needs a claimed account that keeps content: an unclaimed agent, a zero-retention account or one with no credit to bill gets
403 fetch_unavailable; where fetch is not offered,503 fetch_disabled.
Spend limits
Cap what a project can spend per day, week, or month — on top of the credit balance, whichever is lowest. Set them here (owner or admin) on the project's Spend limits page, or from any key over the API. Amounts are micro-dollars (1000000 = $1); 0 or an omitted field means no cap on that window. Windows are calendar UTC (day from 00:00, week from Monday, month from the 1st).
# read current caps + spend today (against the ceiling), this week and this month (against their caps)
curl -s https://api.jevlin.ai/v1/limits -H "Authorization: Bearer $SEARCHROUTER_API_KEY"
# set a $5/day and $50/month cap (partial update: omit a window to leave it)
curl -s https://api.jevlin.ai/v1/limits \
-H "Authorization: Bearer $SEARCHROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"daily_micros": 5000000, "monthly_micros": 50000000}'A cap set through this endpoint binds on the next control-plane sweep, up to a minute later; one set in the Limits tab of the console binds within seconds. Hitting one returns 402 spend_ceiling. Reading or setting limits spends nothing and works even at the ceiling or out of credit, so a capped account can always raise its own budget.
Rate-limit headers, safe retries and key scopes
Every metered response carries the rate windows as they stand after it was charged: X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds) for the per-minute bucket, and the same three with -Hour for the hourly one. A window with no limit sends no header. A 429 carries Retry-After; wait that long. GET /v1/account returns the same numbers without spending anything.
Send Idempotency-Key: <uuid> on POST /v1/search to make a retry safe: the same key with the same body within 24 hours returns the first response (marked Idempotent-Replayed: true) and nothing runs or is billed twice. While the first is still running you get 409 idempotency_in_progress (retry after Retry-After); the same key with a different body is 422 idempotency_key_reused. Ignored on the stream route.
| Scope | Covers |
|---|---|
| search | POST /v1/search, /v1/search/stream, /v1/fetch, /v1/turns, /v1/feedback, /v1/judge |
| read | GET /v1/traces, /v1/traces/{id}, /v1/limits, /v1/account |
| limits | POST /v1/limits |
Pick scopes when you issue a key on the API keys page; a key with no scopes may do everything. A call outside a key's scopes is 403 scope, naming the missing scope. Keys can also expire (1–365 days), carry rate limits of their own, and be rotated with an overlap during which the old key still works.
Webhooks: when a slow-lane arm lands
Research arms can land after the response went out. Set a webhook on the project's Limits page (owner or admin: an https URL on a public host and a 16–256 byte secret) and the gateway POSTs one search.slow_lane.landed event per landed arm: request_id, tenant_id, provider, status, cost_micros, landed_at — no query and no results; read those from the trace.
Each delivery has X-Jevlin-Event, X-Jevlin-Timestamp (unix seconds) and X-Jevlin-Signature: sha256=<hex>. Before trusting it: compute HMAC-SHA256 of timestamp + "." + raw body with your secret, compare in constant time, and reject a timestamp more than a few minutes old. Answer 2xx quickly and de-duplicate on request_id + provider: a delivery can arrive more than once, and is retried up to three times on a 5xx, 408, 429 or a timeout.
mac = hmac.new(secret, f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest("sha256=" + mac, request.headers["X-Jevlin-Signature"])The trace is the record; the webhook is the nudge. A client that must not miss a landing reads the trace back.
For AI agents
Using search-router from Claude or another agent? Give it the skill file. It carries the full contract, self-registration, the feedback vocabulary, and the error discipline: 401 stop, 402 tell the human, 429 honor Retry-After, never retry-loop on billing errors.
For Twilight MINIS servers
Running a MINIS Slot? The server-to-server reference covers verifying enrollment tokens against our JWKS, the OAuth exchange, the second issuance path for claimed agents, and resolving request ids into user_id, status, and cost via POST /v1/verifications.
Providers by share of spend
- Retrieval / index 70.6%
- Answer engines 19.1%
- Research agents 10.3%
Where the fan-out spends across the platform, by category — retrieval indexes, answer engines, and slow-lane research agents. Aggregated across all projects through 2026-09-14. Your own project's spend and provider mix are on its Reports page in the console.