Universal search
GET https://api.onekey.dev/v1/search/universal?q=... takes one plain-language query and returns one ranked list. It picks the platforms, sends each a search that fits its own rules, runs the searches in parallel, and merges the answers by rank. It is built only from pieces that already exist and have their own pages: the search endpoints, multi-platform search, rank fusion, and the optional relevance and themes steps.
Nothing is ranked by a judgment of ours unless you asked for one. The planner and the fusion are rules and arithmetic. The only model involved is the optional AI part, and the response says which method produced it.
curl "https://api.onekey.dev/v1/search/universal?q=rust%20async%20runtime%20on%20hackernews%20and%20devto" \
-H "x-api-key: YOUR_API_KEY"When to use it#
- You have a question in words, not a list of platforms, and you want one merged result.
- You want to see exactly what was asked of each platform. The plan is in the answer.
- You want the per-stage cost and timing in the answer, not a single opaque number.
If you already know the platforms and the exact query for each, /v1/search/multi is the same fan-out without the planner.
The pipeline#
Every call runs up to five stages, in this order. metadata.pipeline lists all five with a status (ok, partial, failed, skipped or unavailable), the milliseconds it took, and the credits it cost.
| # | Stage | What runs | Price |
|---|---|---|---|
| 1 | plan | The planner reads the query and chooses platforms and a sub-query per platform. With plan=ai it adds one metered call (see AI planning). | 0 for the default plan. |
| 2 | fan_out | One search per planned platform, in parallel, through the same machinery as multi-platform search: each leg is isolated, so one failing platform does not fail the call. | The legs that succeeded, each at its endpoint's price. A cache hit is free, a failed leg is refunded. |
| 3 | rerank | Only with relevance=. Each row is judged against the cleaned query; filter drops rows under threshold before fusion. | Metered per judged row. |
| 4 | fuse | Reciprocal rank fusion (k = 60). When a relevance step ran, judged rows come first by on_topic, ties by fusion order. | 0. |
| 5 | cluster | Only with themes=true. Rows are clustered by their words and each cluster is named. | Metered. |
metadata.credits_charged is the sum of the credits of the five stages. The request log gets one 0-credit line per stage; the money is on the per-leg lines and the AI lines, as everywhere else, so nothing is counted twice.
Parameters#
GET with query parameters, or POST with the same names as a JSON body (platforms may then be a list). Unknown parameters are refused with INVALID_PARAMS.
| Parameter | Values | What it does |
|---|---|---|
q | text, required, up to 200 characters | The query. Platform hints in it (below) choose platforms and are removed before searching. |
platforms | comma list | Search exactly these platforms. Each must have a search endpoint, else UNSUPPORTED_PLATFORM. Wins over hints in q. |
category | a docs category id | Restrict automatic selection to one category. |
max_platforms | 1 to 12, default 6 | Cap on the platform list. Naming more platforms than this is a free INVALID_PARAMS that tells you to raise it. |
limit_per_platform | 1 to 100 | Trim each platform's rows before fusion. The leg is still fetched and charged at its own page size. |
plan | deterministic (default) or ai | See AI planning. |
relevance | score or filter | Judge each row against the cleaned query. See Relevance. |
threshold | 0 to 1, default 0.5 | Cut-off for relevance=filter. |
themes | true | Cluster the kept rows and name each cluster. See Themes. |
strict | true (default) or false | What happens when an AI step was asked for and nothing could be judged. See Without the AI part. |
dry_run | true | Plan and price the call without running it. See Dry run. |
include and label are not accepted here. For per-row labels use /v1/search/multi or the brand mentions report.
The planner#
The planner is deterministic. The same query and the same catalog always give the same plan, and the plan is returned in metadata.plan.
Platform hints#
Words in the query can choose the platforms:
- A preposition followed by one or more names:
on reddit,from hackernews and devto,in hn, gitlab or bluesky. The prepositions areon,in,from,via,atandacross. - A name followed by a content noun:
github repos about x. r/rustmeans Reddit. The subreddit name stays a keyword, because the Reddit search cannot be limited to a subreddit.- Common aliases work:
hn,hacker news,twitter,bsky,stack overflow,hugging face,dev.to,docker hub,wiki,yt,gh,ig.
The hints, and leading phrases such as "search for", "find me" and "show me", are removed. What is left is the cleaned query. A hint that names a platform with no search endpoint is recorded with supported: false. If every hint is unsupported, the call is a free NO_SEARCHABLE_PLATFORMS.
What else the plan records#
- Quoted phrases are kept in the sub-query and listed in
phrases. - Keywords are the cleaned query's words minus a short stopword list.
- Language is detected from the cleaned query. It is
nullwhen unclear; short queries arenullby design.
Choosing platforms#
In this order: your explicit platforms, else the hinted platforms, else automatic selection over every search platform (optionally limited by category). Automatic selection is deterministic: stable before beta, then platforms whose search accepts AI enrichment (so rerank and themes can cover them), then cheaper first, then by platform id. It is capped at max_platforms. considered and capped say what was cut.
Fitting the query to each platform#
Each platform's search has its own parameter rules. The planner tries the cleaned query first, then the keywords joined by a space, then the first keyword alone. plan.adapted says which form a platform got, and plan.skipped lists platforms no form fits. Those get no leg: nothing is sent blind.
What the planner cannot do#
- It is string rules, not understanding. A hint needs a preposition or a content noun. A bare platform name, as in "reddit rust", is not a hint.
github issues about xselects GitHub's one search endpoint, which searches repositories, not issues, and the hint'snotesays so. - Tag-style searches get one keyword. Some searches take a tag or a single token rather than free text. The DEV Community search accepts only
[a-z0-9]{1,40}, so it always gets one keyword: "rust "async runtime"" becomesrustthere. The planner fits by the endpoint's parameter rules only. It cannot know what a platform semantically wants beyond what those rules say. - Reranking is per platform. The relevance judgment compares each row with the cleaned query. There is no cross-platform reranking model.
Platforms it can search#
19 platforms have a search endpoint today, and 7 of them also accept relevance and themes. The table is generated from each endpoint's own declaration.
| Platform | Search leg | List price (credits) | Status | Relevance and themes |
|---|---|---|---|---|
| Bluesky | bluesky.search | 1 | stable | yes |
| crates.io | crates.search | 5 | stable | no |
| DEV Community | devto.search | 5 | stable | yes |
| Docker Hub | dockerhub.search | 5 | stable | no |
| GitHub | github.search_repos | 5 | stable | no |
| GitLab | gitlab.search | 5 | stable | yes |
| Hacker News | hackernews.search | 1 | stable | yes |
| Huggingface | huggingface.models | 5 | stable | no |
| Lemmy | lemmy.search_posts | 5 | stable | yes |
| Mastodon | mastodon.search_accounts | 5 | stable | no |
| npm | npm.search | 5 | stable | no |
reddit.search_posts | 2 | beta | yes | |
| SoundCloud | soundcloud.search_tracks | 3 | beta | no |
| Stack Exchange | stackexchange.search | 5 | stable | yes |
| TikTok | tiktok.search_videos | 2 | beta | no |
| Twitch | twitch.search_channels | 5 | beta | no |
| Wikipedia | wikipedia.search | 5 | stable | no |
| X (Twitter) | x.search | 2 | stable | no |
| YouTube | youtube.search_videos | 2 | stable | no |
A platform with "no" in the last column is still fetched and fused. Its rows are never judged (relevance.unsupported lists it) and its fused entries have on_topic: null and come after the judged ones.
A recorded call#
A dry run of a query with two hints, so the plan can be read without spending anything (recorded on the dev API, shortened):
curl "https://api.onekey.dev/v1/search/universal?q=rust%20%22async%20runtime%22%20on%20hackernews%20and%20devto&dry_run=true" \
-H "x-api-key: YOUR_API_KEY"{
"success": true,
"data": {
"plan": {
"method": "deterministic",
"query": "rust \"async runtime\" on hackernews and devto",
"cleaned": "rust \"async runtime\"",
"keywords": ["rust", "async", "runtime"],
"phrases": ["async runtime"],
"language": null,
"hints": [
{ "text": "on hackernews and devto", "platform": "hackernews", "supported": true },
{ "text": "on hackernews and devto", "platform": "devto", "supported": true }
],
"selection": "hints",
"platforms": ["hackernews", "devto"],
"endpoints": { "hackernews": "hackernews.search", "devto": "devto.search" },
"subqueries": { "hackernews": "rust \"async runtime\"", "devto": "rust" },
"considered": 2, "capped": false, "max_platforms": 6, "limit_per_platform": null, "category": null,
"adapted": { "devto": "first keyword only" },
"skipped": {}
},
"legs": [
{ "platform": "hackernews", "endpoint": "hackernews.search", "params": { "q": "rust \"async runtime\"" }, "credits": 1.0, "worst_case_credits": 1.0 },
{ "platform": "devto", "endpoint": "devto.search", "params": { "q": "rust" }, "credits": 5.0, "worst_case_credits": 5.0 }
],
"credits": { "legs_worst_case": 6.0, "legs_list_price": 6.0, "ai": null, "note": "AI steps (relevance, themes, labels) are metered per judged row after the fetch and cannot be priced before the rows exist; cache hits make legs cheaper than the worst case" },
"balance_credits": 790.917,
"balance_covers_worst_case": true,
"ai_enabled": true
},
"metadata": { "recipe": "search_universal", "dry_run": true, "legs": 2, "credits_charged": 0.0, "request_id": "...", "idempotent_replay": false }
}Read the plan: two hints chose the platforms, the DEV Community search got only the first keyword, and the plan says so in adapted.
The response#
Without the AI part, the answer has the same shape as multi-platform search with fuse=rrf. data.<platform> holds each platform's rows, keeping their normal shape. data.fused is always present. metadata adds plan, fuse, pipeline and the usual legs, succeeded, failed and credits_charged.
| Field | Meaning |
|---|---|
data.<platform> | One row per leg: status, data (its rows), error, credits_charged, cached, pagination. |
data.fused | The merged list. Each entry has score, platform, platforms, ranks (rank per platform), key (how duplicates were matched) and item, the row itself. |
metadata.plan | The plan, as above. |
metadata.pipeline | The five stages, in order. |
metadata.credits_charged | Total charged, AI included. |
metadata.extra_credits | Present when an AI step ran: the part of the total that is AI. |
A call whose legs all fail answers with the first leg's error, and nothing is charged. Otherwise a failed platform is a row with its status and error next to the ones that worked.
The optional AI part#
Relevance and themes#
relevance=filter or relevance=score judges each row's on_topic probability against the cleaned query. filter also drops the rows under threshold and lists them in dropped_ids; a row that could not be judged is never dropped. themes=true clusters the kept rows of the platforms that accept enrichment, and names each cluster. Both blocks are the same ones described on Relevance and Themes, inside data beside the rows and fused.
The same query for real, with both steps (recorded on the dev API with the pre-release test provider, heavily shortened; 20 rows were fetched, 4 DEV Community rows were dropped as off topic, 16 were clustered):
curl "https://api.onekey.dev/v1/search/universal?q=rust%20%22async%20runtime%22%20on%20hackernews%20and%20devto&relevance=filter&themes=true" \
-H "x-api-key: YOUR_API_KEY"{
"success": true,
"data": {
"hackernews": { "platform": "hackernews", "status": 200, "data": ["..."], "credits_charged": 9.541, "extra_credits": 4.541, "dropped": 0, "dropped_ids": [] },
"devto": { "platform": "devto", "status": 200, "data": ["..."], "credits_charged": 11.185, "extra_credits": 6.185, "dropped": 4, "dropped_ids": ["devto:4773537", "..."] },
"fused": [
{
"score": 0.016393, "platform": "hackernews", "platforms": ["hackernews"], "ranks": { "hackernews": 1 }, "key": "url", "on_topic": 1.0,
"item": {
"content": { "id": "29493340", "title": "Monoio – A thread-per-core Rust async runtime with io_uring", "url": "https://github.com/bytedance/monoio" },
"author": { "username": "losfair" },
"engagement": { "likes": 158, "comments": 81 },
"computed": { "engagement_rate": null, "language": null, "relevance": { "on_topic": 1.0 } }
}
}
],
"relevance": { "mode": "sync", "legs": 2, "status": "complete", "query": "rust \"async runtime\"", "threshold": 0.5, "rows": 20, "labelled": 20, "dropped": 4, "extra_credits": 10.726, "methodology_version": "fake/1" },
"dropped": 4,
"themes": { "status": "complete", "rows": 16, "clustered": 16, "named": true, "extra_credits": 4.641, "methodology_version": "fake/1", "clustering": "cluster/1", "themes": [ { "theme": "Runtime Rust Async", "size": 10, "terms": ["runtime", "rust", "async", "tokio", "io"] } ] }
},
"metadata": {
"recipe": "search_universal",
"plan": { "...": "as above" },
"fuse": { "method": "rrf", "k": 60, "reranked_by": "on_topic" },
"ai": { "strict": true, "relevance": "filter", "threshold": 0.5, "themes": true },
"legs": 2, "succeeded": 2, "failed": 0, "credits_charged": 25.367, "extra_credits": 15.367,
"pipeline": [
{ "stage": "plan", "status": "ok", "ms": 1.8, "credits": 0.0, "method": "deterministic" },
{ "stage": "fan_out", "status": "ok", "ms": 1414.0, "credits": 10.0, "legs": 2, "succeeded": 2, "failed": 0 },
{ "stage": "rerank", "status": "ok", "ms": 27.7, "credits": 10.726, "mode": "filter", "threshold": 0.5, "dropped": 4, "methodology_version": "fake/1" },
{ "stage": "fuse", "status": "ok", "ms": 0.4, "credits": 0.0, "items": 15, "reranked_by": "on_topic" },
{ "stage": "cluster", "status": "ok", "ms": 15.0, "credits": 4.641, "themes": 7, "methodology_version": "fake/1" }
]
}
}Two things to read honestly here. First, methodology_version here is the test provider's method id: the pre-release test provider, a keyword heuristic. Its on_topic is word overlap: the DEV Community leg was searched for rust only, so it lost 4 of 10 rows against "rust async runtime". A production method would judge differently, and these figures are not a measure of quality. Second, 16 rows were kept and the fused list has 15 entries.
Planning with AI#
plan=ai adds one metered call over the candidate platforms (at most 12, in the deterministic order). The provider scores how useful each platform is for the query (use, 0 to 1) and rewrites the sub-query. Platforms scoring 0.5 or more are kept, capped by max_platforms, and every rewritten sub-query is still fitted to that platform's rules, so an AI plan cannot send a value the endpoint would refuse.
plan=ai falls back to the deterministic plan, and says why. When the provider is off, when it is the pre-release test provider, after a provider failure, and when it selects nothing, the deterministic plan runs at no charge, and plan.ai carries a reason and a note. Recorded on the pre-release test provider:
{
"plan": {
"method": "deterministic",
"selection": "auto",
"platforms": ["bluesky", "devto", "gitlab"],
"subqueries": { "bluesky": "postgres vector search", "devto": "postgres", "gitlab": "postgres vector search" },
"adapted": { "devto": "first keyword only" },
"ai": { "status": "unavailable", "extra_credits": 0.0, "methodology_version": "none", "candidates": 0, "chosen": 0, "reason": "fake_provider", "note": "the fake provider cannot plan; deterministic plan used (nothing charged)" }
}
}The value of reason is provider_off, fake_provider or a provider failure code. The metered path is exercised in tests with a stub provider only: plan=ai has not been run against a production model, and this page makes no claim about the quality of its plans. A dry run skips the AI plan (reason: dry_run), so it really costs 0.
Without the AI part#
When AI is not enabled for your account:
- A call with
relevanceorthemesand the defaultstrict=trueis refused withAI_UNAVAILABLE(503) before anything is fetched, so it costs nothing. - With
strict=falseit answers 200 at the legs' price only:relevance.statusisunavailablewithreason: provider_off, nothing is dropped, themes come back namedTheme 1,Theme 2and so on withnamed: false, and the AI stages inpipelineshowunavailablewith 0 credits. - Planning, fan-out and fusion work exactly as normal.
Dry run#
dry_run=true returns what the call would do, for 0 credits: the plan, each leg with its endpoint, params, list price and worst-case price, your balance, whether the balance covers the worst case, and ai_enabled. Nothing runs and an Idempotency-Key header is ignored. It works even when AI is off (ai_enabled says so).
Only the search legs can be priced beforehand. AI steps are metered per judged row after the rows exist, so a dry run says credits.ai: null and explains why. Use the worst case as the ceiling for the legs and add the AI part separately.
Costs#
- Search legs are the normal price of each platform's search endpoint (see the table above), charged per leg that succeeded. A cache hit is free, and a failed leg is refunded.
- Planning, fusion and the request itself are free. A default plan costs 0.
- Relevance and themes are metered per judged row, exactly as on AI pricing: reserved, settled to actual, cached and unjudged rows free.
- A worst-case check runs first. If the legs' worst case is above your balance the call is refused before anything runs, with
INSUFFICIENT_CREDITS. An AI reservation that your key's credit limit cannot cover isKEY_BUDGET_EXCEEDEDnaming the step; the legs stay paid, and because their data is cached, a retry is a free hit. - A call counts as one request against your rate limit.
Measured on the pre-release test provider (synthetic usage, so it shows how the charge scales, not what production judgments will cost): the call in the recorded example cost 10 credits for the two legs, 10.726 for 20 relevance judgments (0.54 per row) and 4.641 for naming 7 clusters, 25.367 in all. The same call again cost 0.6: two cache hits on the legs and every judgment cached. Do not budget AI from these numbers.
Streaming#
Send Accept: text/event-stream and the call answers as Server-Sent Events. Frames arrive in this order:
| Event | How many | Payload |
|---|---|---|
plan | One, first | The plan, before anything is fetched. |
leg | One per leg, as each settles | One row of the JSON form, as on Streaming. |
fused | One | The complete data.fused list. |
enrich | One per AI step that ran (relevance, themes) | {"step": "relevance", "summary": {...}, "legs": [...]} |
done | One, last | The JSON form's metadata (pipeline and plan included) plus credits_remaining. |
Recorded (shortened), with relevance=score on the pre-release test provider:
event: plan
data: {"method":"deterministic","hints":[...],"selection":"hints","platforms":["hackernews","devto"],"subqueries":{"hackernews":"zig compiler","devto":"zig"},...}
event: leg
data: {"index":0,"platform":"hackernews","endpoint":"search","params":{"q":"zig compiler"},"status":200,"data":[...],"credits_charged":5.0,"cached":false,...}
event: leg
data: {"index":1,"platform":"devto","endpoint":"search","params":{"q":"zig"},"status":200,"data":[...],"credits_charged":5.0,"cached":false,...}
event: fused
data: [{"score":0.016393,"platform":"hackernews","ranks":{"hackernews":1},"on_topic":1.0,...}, ...]
event: enrich
data: {"step":"relevance","summary":{"legs":2,"status":"complete","rows":20,"labelled":20,"extra_credits":10.253,"methodology_version":"fake/1",...},"legs":[...]}
event: done
data: {"recipe":"search_universal","legs":2,"succeeded":2,"credits_charged":20.253,"extra_credits":10.253,"credits_remaining":745.297,"pipeline":[...],"plan":{...},...}Anything refused up front (bad parameters, an unsupported platform, a worst case above your balance or key limit) is an ordinary JSON error, never a stream. Check the Content-Type before parsing. A replayed stream has the same frame order.
Idempotency#
GET and POST share one fingerprint, so an Idempotency-Key sent on one form replays on the other. A replay lasts 24 hours and answers with credits_charged: 0 and idempotent_replay: true, with the stored plan and pipeline included. A streamed GET that carries the header is INVALID_REQUEST (400); a streamed POST accepts it. A different set of AI parameters is a different request (IDEMPOTENCY_KEY_REUSED).
POST#
The same call as a JSON body, with a platform list and a per-platform cap:
curl -X POST "https://api.onekey.dev/v1/search/universal" \
-H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
-d '{"q": "postgres vector search", "platforms": ["hackernews", "devto"], "limit_per_platform": 3}'Recorded: 200, credits_charged: 5.0 (the Hacker News leg was a cache hit), data.fused with 6 entries, and plan.subqueries of "postgres vector search" for Hacker News and "postgres" for DEV Community.
Errors#
| Status | Type | When |
|---|---|---|
| 422 | NO_SEARCHABLE_PLATFORMS | The platform hints, the category or the automatic selection left no platform with a search endpoint, for example x on instagram. Free. |
| 422 | UNSUPPORTED_PLATFORM | A platform in platforms has no search endpoint. Free. |
| 422 | INVALID_PARAMS | q missing, empty after removing hints, or over 200 characters; a value out of range; an unknown parameter; include or label. Free. |
| 402 | INSUFFICIENT_CREDITS | The worst case of the legs is above your balance. Nothing ran. |
| 402 | KEY_BUDGET_EXCEEDED | The key's limit cannot cover the legs, or an AI reservation. |
| 503 | AI_UNAVAILABLE | relevance or themes was requested, AI is not enabled for your account, and strict is true. Free, before any fetch. |
| 429 | AI_BUDGET_EXCEEDED | A daily AI allowance is used up and nothing could be judged. |
The recorded NO_SEARCHABLE_PLATFORMS for a query whose only hint has no search endpoint:
{
"success": false,
"data": null,
"error": {
"code": "no_searchable_platforms",
"type": "NO_SEARCHABLE_PLATFORMS",
"message": "The platform(s) named in the query have no search endpoint: instagram",
"doc_url": "https://onekey.dev/errors/NO_SEARCHABLE_PLATFORMS"
}
}Limits, in one list#
- The planner is string rules: hints need a preposition or a content noun.
- Tag-style searches receive one keyword.
- Reranking is per platform against the cleaned query. There is no cross-platform model.
- AI parts run on the pre-release test provider only.
plan=aiis untested against a production model. - Timings (
ms) forfan_outcover the per-leg pipeline; the AI stages'msare the enrichment calls.
Next#
- Multi-platform search: the fan-out and fusion without the planner.
- Relevance, Themes and AI pricing.
- Reports: brand mentions, creator vetting and demand signals, built on the same legs.
- The hosted MCP server exposes this as the
search_universaltool.
Try it: Trend snapshot