API
JSON by default, markdown with Accept: text/markdown. Everything returned was crawled, extracted and ranked by this engine; nothing is proxied from
another one. No key is needed for a modest budget per client; a search key from PkgFire lifts it.
Endpoints
| GET | /v1/search
|
|---|---|
| GET | /v1/page
|
| POST | /v1/fetch Body |
| GET | /v1/similar
|
| GET | /v1/suggest
|
| GET | /v1/map
|
| GET | /v1/page/changes
|
| GET | /v1/feed
|
| POST | /v1/feedback Body |
| POST | /mcp Streamable HTTP MCP server with the tools |
Examples
curl -s "https://search.pkgfire.org/v1/search?q=asyncio+taskgroup+cancellation&n=3&fields=passages&max_chars=6000" curl -s "https://search.pkgfire.org/v1/search?q=react+router&n=3&fields=markdown&max_chars=20000" curl -s -H "Accept: text/markdown" "https://search.pkgfire.org/v1/search?q=site:docs.python.org+asyncio" curl -s "https://search.pkgfire.org/v1/page?url=https://ch-ui.com/&fields=markdown" curl -s -X POST "https://search.pkgfire.org/v1/fetch" -d '{"url":"https://www.rfc-editor.org/rfc/rfc9309.html","fields":["text"]}' curl -s "https://search.pkgfire.org/v1/similar?url=https://pypi.org/project/fastapi" Query operators
The same operators as the search box work in q:
| Operator | Meaning |
|---|---|
site:docs.python.org | that host or its subdomains |
type:doc | doc, package, code, article, reference, other |
ecosystem:npm | npm, pypi, go, cargo |
package:react-router | exactly that package |
lang:en | two-letter language |
since:2026-01-01 until:… | published or fetched in that window |
"exact phrase" | the text must contain it |
-word | the text must not contain it |
intitle:word inurl:word | word in the title, or in the address |
sort:new | newest first among the matches |
version:7 | the version the answer is about: pages naming it first ("v7", "3.12" in the words work too) |
Operators alone, with no words, list the newest pages that match them; those browse queries never trigger a live lookup. The response echoes what was
understood in parsed.
What comes back
{
"q": "react router",
"terms": ["react", "router"],
"parsed": { "terms": ["react", "router"], "filters": {}, "ops": [] }, // + "lang": "pt" when the query reads as another language
"term_coverage": "full", // full | partial | none: about the words only, not whether a page answers
"coverage": "full", // the same, its name before 0.18.0
"limits": [ // what is known to be weak about this answer; [] when nothing is
{ "code": "no_answer_section", "detail": "…" } // thin | term_dropped | meaning_only | respelled | version_unmatched | no_answer_section
],
"thin": false, // true: fewer than ten pages in the whole index match (about the corpus)
"total_candidates": 47,
"took_ms": 31,
"offset": 0,
"live": false, // true: the index was thin, pages were fetched right now
"discovered": 0, // how many that live lookup indexed
"hits": [
{
"url": "https://…", "title": "…", "description": "…",
"host": "…", "lang": "en",
"type": "doc", // doc | package | code | article | reference | other
"ecosystem": "npm", "package": "react-router",
"fetched_at": "2026-10-25 20:25:47",
"published_at": null, // or a timestamp
"content_hash": "…", // sha256 of the extracted text
"word_count": 1190,
"score": 51.4,
"similarity": 0.87, // cosine to the query's vector (with the embedding sidecar)
"via": "meaning", // only when the page matched by vector alone, not by the words
"version_match": "url", // with a version asked: url | title | other (another version) | absent
"snippet": "…", // fields=snippet (default)
"passages": [ // fields=passages: the sections that answer, best first
{ "heading": ["Coroutines and Tasks", "Task Groups"], "url": "https://…#task-groups",
"text": "…", "score": 0.81 } // markdown, code blocks whole; "truncated": true when cut
],
"text": "…", // fields=text
"markdown": "…", // fields=markdown; headings keep the page's anchor as {#id}
"truncated": true, // text/markdown cut to max_chars; "full_chars" is the whole length
"full_chars": 48211
}
]
} The same query against the same index gives the same order. term_coverage (coverage before 0.18.0) is about the words only: full when every term matched, partial when no
page had every term and the engine left out the most common one whose absence gives pages, tried from the most common down (parsed.dropped names
it; three or more terms only), and a word almost no page has is respelled from the index's own vocabulary when that finds far more
(parsed.corrected lists each from/to; parsed.instead is the query with those words quoted, which searches
them as typed), none when the index has nothing. thin is about the corpus instead: true when fewer than ten pages in the whole index
match, so even a full result may be beside the point (the index is for developers: documentation, packages, code, developer news). limits lists what the
engine knows is weak about the answer, as codes to branch on: no_answer_section means the best pages share the words but no section of them is
clearly about the question. fields=passages returns each page's best sections with their heading path and a link to the place; max_chars caps passages, text and markdown across all hits, cut at a block boundary, never inside a code block. A version in the query
(v7, 3.12, postgres 17) or version= ranks pages that name it ahead and pages for another version behind; it is
not a filter, and since uses the publication date, or the fetch date when a page states none. parsed.lang is the language
the query reads as, from its script, letters and words; pages in that language rank first. Errors are { "error": { "code", "message" } } with a stable code: missing_q, missing_url, not_indexed, rate_limited, not_found, internal, and with a key invalid_key and no_credits (below).
When the index is thin
Fewer than three hits and the engine looks right then, within a few seconds, at sources with structure rather than at another search engine: the query as an
address, a host or a package name; its own frontier; registry search APIs. What it finds is fetched, indexed and answered from the index, so the second ask is
instant. The response says so with live: true and discovered. Pass live=0 to skip it.
Keys and prices
Search keys come from your PkgFire account: sign in at pkgfire.org, open API keys and create a key of
kind Search. It starts with pks_live_ and is shown once. Send it on every call to /v1/* and /mcp:
curl -s -H "Authorization: Bearer pks_live_…" "https://search.pkgfire.org/v1/search?q=asyncio+taskgroup&n=3&fields=passages" 100 free requests a day per account, then 1 credit per request. The day is UTC and the 100 are shared by all of the account's search
keys. A request is one call to /v1/* (except /v1/feedback, which is free) or one MCP tools/call (the feedback tool is free too); MCP's initialize and tools/list are free, and so is a call the engine fails. Credits are bought and the charges shown on
PkgFire's credits page, where search appears as one line per day. A key makes at most 120 requests a minute; every answer to a keyed request says where
it stands with X-RateLimit-Limit and X-RateLimit-Remaining.
| Answer | Meaning |
|---|---|
401 invalid_key | Unknown, revoked or expired key. A PkgFire CLI key (pkf_live_) gets this too: search keys start with pks_live_. |
402 no_credits | Today's free requests are used up and the account has no credits left (or a payment is on hold). The message links to the credits page. |
429 rate_limited | Over 120 requests a minute for this key. Retry-After says how many seconds to wait. |
Limits
Without a key, clients get 30 searches a minute, counted per address; past that the answer is 429 rate_limited. q is cut at 500
characters, n at 50, offset at 200.
MCP
{
"mcpServers": {
"pkgfire-search": {
"type": "http",
"url": "https://search.pkgfire.org/mcp",
"headers": { "Authorization": "Bearer pks_live_…" }
}
}
} Claude Code:
claude mcp add --transport http search https://search.pkgfire.org/mcp --header "Authorization: Bearer pks_live_…" Without the header the server works on the anonymous budget; with a search key each tool call is one request.
The tools take the same parameters as the endpoints and return the same JSON. Ask for fields: ["text"] or ["markdown"] on search so one call finds and reads.