skip to content

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

q (required, operators allowed), n (1–50, default 10), offset (0–200), fields (snippet default, text, markdown; several, comma separated), site, type, ecosystem, lang, since, until as parameters instead of operators, live=0 to answer from the index only.

GET/v1/page

url (required), fields (markdown default, text). One stored page with its citation. 404 not_indexed when the index has none. With Accept: text/markdown the body is the page itself, title and address first.

POST/v1/fetch

Body { "url": "…", "fields": ["markdown"] } (or GET ?url=). Fetches that address right now through the same extractor, under the same manners, and returns it like a hit. Pages marked noindex are returned to you and never kept.

GET/v1/similar

url of an indexed page, or POST { "text": "…" }. Pages like it, as a search result.

GET/v1/suggest

q is a prefix, n at most 10. Completions from the index only, in a fixed order: queries that found something, package names, hosts, page titles. Each has text, kind and a hint.

GET/v1/map

q (a query) or url (one stored page), n (pages, default 40). The link graph from the engine's own crawl: nodes (pages with their score, hosts they point at), edges and a per-host summary in hosts. Drawn at /map.

GET/v1/page/changes

url and since (a date like 2026-10-20, or a time). What changed in a stored page from the version current then to now, as a line diff: added, removed, lines. Without since, the last change. /v1/page/history?url= lists the versions. The engine keeps a page's earlier text when its crawler sees it change.

GET/v1/feed

q and the filters of /v1/search. The query as a feed: the newest pages that match its words, as Atom, or JSON Feed with format=json. An item's date is the page's own, else when its text last changed here. Cached ten minutes.

POST/v1/feedback

Body { "q": "…", "useful": true, "url": "…", "pos": 0 }: whether a search answered, after you used it. q exactly as searched; url the result that answered (or leave it out for the answer as a whole). Queries that keep failing get crawled for, and pages that answer will rank higher. Stored: the query's hash, the page, the verdict. Answers 204.

POST/mcp

Streamable HTTP MCP server with the tools search, fetch, package, similar, changes and feedback.

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:

OperatorMeaning
site:docs.python.orgthat host or its subdomains
type:docdoc, package, code, article, reference, other
ecosystem:npmnpm, pypi, go, cargo
package:react-routerexactly that package
lang:entwo-letter language
since:2026-01-01 until:…published or fetched in that window
"exact phrase"the text must contain it
-wordthe text must not contain it
intitle:word inurl:wordword in the title, or in the address
sort:newnewest first among the matches
version:7the 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.

AnswerMeaning
401 invalid_keyUnknown, revoked or expired key. A PkgFire CLI key (pkf_live_) gets this too: search keys start with pks_live_.
402 no_creditsToday'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_limitedOver 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.