Web search API
Search the web, read pages and get cited answers through Helix's Exa-compatible API, using Exa's own SDKs with only the base URL and key changed.
Helix serves a web search API with the same wire format as the Exa API (OpenAPI 2.0.0). It has four endpoints: search the web, fetch the contents of pages, find pages similar to a URL, and answer a question with citations. The official exa-py and exa-js SDKs work against it unmodified. You change the base URL and the API key, nothing else.
Helix agents use the same API. The agent web search and browser skills go through it, so they get the same results, page cache and security checks.
Whether requests are served by Helix's own search stack or forwarded to Exa is a deployment setting, and the response format is the same either way. Operators configure it in Configure web search.
Base URL and authentication
The base URL is your Helix URL followed by /api/v1/websearch:
https://<your-helix>/api/v1/websearchDon't add a trailing slash. The SDKs build request URLs by appending /search, /contents and so on to the base URL.
Authenticate with a Helix API key, sent either way:
x-api-key: <key>(what the Exa SDKs send)Authorization: Bearer <key>
Requests are attributed to the key's user and organisation. The examples below use these variables:
export HELIX_URL="https://helix.example.com" # your Helix URL
export HELIX_API_KEY="hl-..."Quickstart: Python (exa-py)
pip install exa-py==2.28.0import os
from exa_py import Exa
exa = Exa(
api_key=os.environ["HELIX_API_KEY"],
base_url=os.environ["HELIX_URL"].rstrip("/") + "/api/v1/websearch",
)
# Search
r = exa.search("go generics tutorial", num_results=3)
for result in r.results:
print(result.title, result.url)
# Search and fetch contents in one call
r = exa.search_and_contents(
"go generics tutorial",
num_results=3,
text={"max_characters": 500},
highlights=True,
summary=True,
)
# Contents by result id, or by URL
c = exa.get_contents([r.results[0].id], text=True)
c = exa.get_contents(["https://go.dev/doc/tutorial/generics"], text={"max_characters": 200})
# Pages similar to a URL
s = exa.find_similar("https://go.dev/doc/tutorial/generics", num_results=3, exclude_source_domain=True)
# A cited answer, then the same answer streamed
a = exa.answer("In which Go version were generics introduced?")
print(a.answer, [c.url for c in a.citations])
for chunk in exa.stream_answer("In which Go version were generics introduced?"):
print(chunk.content or "", end="")Quickstart: JavaScript (exa-js)
npm install exa-js@2.28.0import Exa from "exa-js";
const exa = new Exa(
process.env.HELIX_API_KEY,
process.env.HELIX_URL.replace(/\/$/, "") + "/api/v1/websearch",
);
const r = await exa.search("go generics tutorial", {
numResults: 3,
contents: { text: { maxCharacters: 500 }, highlights: true, summary: true },
});
const c = await exa.getContents(["https://go.dev/doc/tutorial/generics"], { text: true });
const s = await exa.findSimilar("https://go.dev/doc/tutorial/generics", {
numResults: 3,
excludeSourceDomain: true,
});
const a = await exa.answer("In which Go version were generics introduced?");
for await (const chunk of exa.streamAnswer("In which Go version were generics introduced?")) {
process.stdout.write(chunk.content ?? "");
}These calls match the SDK smoke tests in the Helix repository (integration-test/websearch-sdk/), which run exa-py 2.28.0 and exa-js 2.28.0 against a live Helix. Newer SDK versions may send request fields Helix doesn't know yet. Helix rejects unknown fields with a 400 instead of ignoring them, so pin the SDK version.
Quickstart: curl
curl --fail-with-body "$HELIX_URL/api/v1/websearch/search" \
-H "x-api-key: $HELIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "go generics tutorial",
"numResults": 3,
"contents": { "text": { "maxCharacters": 500 } }
}'{
"requestId": "...",
"results": [
{
"id": "https://go.dev/doc/tutorial/generics",
"url": "https://go.dev/doc/tutorial/generics",
"title": "Tutorial: Getting started with generics - The Go Programming Language",
"text": "..."
}
],
"resolvedSearchType": "auto",
"searchTime": 2140.5,
"costDollars": { "total": 0.01, "contents": { "text": 0.003 } }
}Every response carries a requestId, which is also sent in the x-request-id header. Quote it when reporting a problem.
# Contents for URLs (or "ids": [...])
curl --fail-with-body "$HELIX_URL/api/v1/websearch/contents" \
-H "x-api-key: $HELIX_API_KEY" -H "Content-Type: application/json" \
-d '{"urls": ["https://go.dev/doc/tutorial/generics"], "text": true}'
# A streamed answer
curl -N "$HELIX_URL/api/v1/websearch/answer" \
-H "x-api-key: $HELIX_API_KEY" -H "Content-Type: application/json" \
-d '{"query": "In which Go version were generics introduced?", "stream": true}'Command line
The helix CLI has a websearch command group. It reads HELIX_URL and HELIX_API_KEY from the environment.
helix websearch search "go generics tutorial" -n 5
helix websearch search "go generics tutorial" --type deep --text 2000 --json
helix websearch contents https://go.dev/doc/tutorial/generics --max-chars 5000
helix websearch answer --stream "In which Go version were generics introduced?"| Command | Flags |
|---|---|
search <query> | -n, --num-results (default 10), --type (auto, lite, deep-lite, deep), --category, --text <chars> (include page text), --json |
contents <url-or-id>... | --max-chars (0 = no limit), --json. Arguments starting with http:// or https:// are sent as urls, anything else as ids. |
answer <question> | --stream, --json (not both) |
Inside a Helix sandbox the CLI authenticates as the session's user, so coding agents can use these commands directly.
Endpoints
All endpoints are POST with a JSON body, relative to the base URL.
| Endpoint | Exa reference | What it does |
|---|---|---|
/search | Search | Search the web. Optionally fetch contents for each result, or synthesise an outputSchema answer from them. |
/contents | Get contents | Text, highlights, summaries, links and subpages for up to 100 URLs or result ids. |
/findSimilar | Find similar | Pages similar to a URL. Deprecated by Exa; see Differences from Exa. |
/answer | Answer | Search, read the top results, and answer the question with citations. |
Request and response fields follow Exa's reference. The parts that behave differently are described below.
Search types
type | Behaviour |
|---|---|
auto (default) | One query to the configured search engines. instant and fast are treated as auto. |
lite | Like auto, but rejects the fields Exa rejects for lite (crawl dates, text filters, outputSchema, stream, summaries, extras, freshness options and subpages). |
deep-lite, deep | An LLM adds 2 (deep-lite) or 4 (deep) rewritten queries, and the results are merged. additionalQueries (up to 10) is accepted only with these types. deep-reasoning is treated as deep. |
Any other type returns FEATURE_DISABLED.
Text limits
Without maxCharacters, page text is capped at 10,000 characters per result on /search and /findSimilar, and at 5,000,000 on /contents (Exa's default there).
Document ids
A result's id is its page URL, normalised: lower-case scheme and host, no fragment, no default port, and / for an empty path. You can pass any id to /contents, whether or not a search fetched that page before, and passing a URL as an id works the same as passing it in urls.
Freshness and the page cache
Fetched pages are cached. Each item in a /contents response's statuses says where its page came from: "source": "cached" or "source": "crawled".
Control freshness with maxAgeHours (in /contents, or inside contents on /search and /findSimilar):
maxAgeHours | Behaviour |
|---|---|
| omitted | Use the cached copy if there is one, however old; otherwise fetch. |
-1 | Cache only. A page that isn't cached fails with SOURCE_NOT_AVAILABLE. |
0 | Always fetch. |
1 to 720 | Fetch again if the cached copy is older than this many hours. |
The deprecated livecrawl field still works: never means -1, always means 0, and fallback, preferred and auto mean the default. You can't send both livecrawl and maxAgeHours. livecrawlTimeout sets the per-page fetch timeout in milliseconds (default 10,000, maximum 90,000).
Streaming
Streams are server-sent events. Each event is a data: line with a JSON object, and the stream ends with data: [DONE]. Both SDKs handle this.
/answer with "stream": true sends answer text as OpenAI chat-chunk deltas (choices[0].delta.content), then an event with citations, then an event with costDollars and the requestId. With an outputSchema, the whole JSON answer arrives as one delta.
/search with "stream": true streams only when outputSchema is also set. Event types are results, text-delta (text schemas only), grounding, done and error. Without outputSchema, stream is ignored and you get a normal JSON response, as with Exa.
If a request fails before the first event, you get a normal HTTP error response. If it fails mid-stream, the stream carries an error event before [DONE]. For /answer that event is {"tag": "ERROR", "payload": {"error": {"code": 502, "message": "..."}, "requestId": "..."}}. For /search it's an event with "type": "error".
Warnings
When Helix approximates or ignores part of a request, the response includes a warnings array instead of failing. Each warning has a code, a path (a JSON Pointer to the request field, such as /contents/highlights/highlightsPerUrl, or empty for the whole request) and a human-readable message.
"warnings": [
{
"code": "APPROXIMATED_FIELD",
"path": "/category",
"message": "\"people\" is approximated by adding \"linkedin\" to a general web search"
}
]| Code | Meaning | Examples |
|---|---|---|
IGNORED_FIELD | The field had no effect. | startCrawlDate / endCrawlDate (Exa ignores them too), userLocation coordinates, objective outside deep search, systemPrompt without outputSchema, deprecated highlightsPerUrl, richLinks and codeBlocks, an unknown userLocation.country |
APPROXIMATED_FIELD | The field was applied, with lower fidelity than Exa. | category other than news, more than 10 includeDomains or excludeDomains, published-date filters on results with no known date, includeText / excludeText |
PARTIAL_RESULTS | Some queries, search engines or pages failed; you got what the rest returned. | A search engine didn't respond, a later results page failed, some results or subpages couldn't be fetched |
Treat warnings as developer feedback. Log them; don't parse the message text.
Errors
Errors use Exa's envelope:
{ "requestId": "...", "error": "numResults must be between 0 (the default, 10) and 100", "tag": "INVALID_REQUEST" }| Status | tag | When |
|---|---|---|
| 400 | INVALID_REQUEST | The request breaks the API contract: an unknown field, a value out of range, invalid JSON, or a combination Exa also rejects (for example livecrawl with maxAgeHours). |
| 400 | FEATURE_DISABLED | A recognised field this deployment can't honour. See Differences from Exa. |
| 401 | (none) | Missing or invalid API key. This error comes from Helix's authentication layer as plain text, not in the Exa envelope. |
| 402 | NO_MORE_CREDITS | Your organisation's credit balance can't cover the request. |
| 404 | NOT_FOUND | /answer, or a /search with outputSchema, found no results to answer from. |
| 502 | UPSTREAM_ERROR | None of the search results could be fetched to answer from. |
| 503 | UPSTREAM_ERROR | Every search engine query failed. |
| 500 | INTERNAL_ERROR | Anything else. Report it with the requestId. |
The SDKs raise on any status of 400 or above: exa-py raises ValueError with the status and body, and exa-js throws an error with statusCode.
Per-page errors
A page that can't be fetched doesn't fail a /contents request. It's left out of results and reported in statuses:
"statuses": [
{ "id": "https://example.com/", "status": "success", "source": "crawled" },
{ "id": "https://example.com/missing", "status": "error", "error": { "tag": "CRAWL_NOT_FOUND", "httpStatusCode": 404 } }
]| Tag | Meaning |
|---|---|
CRAWL_NOT_FOUND | The site answered 404 or 410. |
CRAWL_TIMEOUT | The fetch exceeded livecrawlTimeout. |
CRAWL_HTTP_ERROR | Another HTTP error status, or too many redirects. |
SOURCE_NOT_AVAILABLE | The page couldn't be reached, or maxAgeHours: -1 was set and the page isn't cached. |
BLOCKED_DESTINATION | The URL, or a redirect it led to, points at a private or internal address. |
UNSUPPORTED_DOCUMENT | The content type can't be extracted, or the response is larger than 20 MB. |
On /search and /findSimilar, a result whose page couldn't be fetched is returned without contents, and a PARTIAL_RESULTS warning says how many failed.
Pricing
On deployments with billing enabled, requests are charged in Helix credits from your organisation's wallet (USD). Prices mirror Exa's list prices, and every response reports what the request cost in costDollars: the total, with content charges itemised under contents. The operator can change these prices, so check with them if your deployment differs.
| Item | Price (USD) |
|---|---|
/search, type lite | $0.004 per request |
/search, type auto | $0.007 per request |
/search, type deep or deep-lite | $0.012 per request |
| Each result above 10 | $0.001 |
Contents: each page, per content type (text, highlights, summary) | $0.001 |
/answer | $0.005 per request |
/findSimilar | $0.007 per request, plus results above 10 and contents as for /search |
Subpages count as pages. Pages that fail to fetch and requests that fail aren't charged. For example, an auto search for 10 results with text and highlights costs $0.007 + 10 × $0.001 × 2 = $0.027.
When your balance runs out, requests fail with 402 NO_MORE_CREDITS. Agent web search and browser tool calls go through the same API, and are charged like the rest of an agent's usage.
Differences from Exa
The API matches Exa's wire format, but the default search stack is not Exa's. Know these differences before you rely on it.
No neural index. Exa searches its own embedded crawl of the web, so descriptive queries such as "startups building X" retrieve pages by meaning. Helix sends keyword queries to the search engines the operator configured, through SearXNG. deep search narrows the gap by having an LLM rewrite the query, but results for descriptive queries are noticeably weaker. If the operator has set the deployment to use Exa as its provider, you get Exa's results.
Filters are approximations.
category:newsuses the engines' news search. The other categories add a keyword to the query (peopleadds "linkedin",financial reportadds "annual report 10-K") and return anAPPROXIMATED_FIELDwarning.includeDomains/excludeDomains: up to 10 domains are sent to the engines assite:operators, and every result is then filtered exactly. With more domains, filtering happens only after searching, so you may get fewer results thannumResults.startPublishedDate/endPublishedDate: a coarse engine time range plus a filter on known dates. Results with no known date are kept and reported in a warning. Exa estimates a date for every page; Helix can't.includeText/excludeText: filter on fetched page text, so they force a fetch and drop results that couldn't be fetched.userLocation: the country picks the search locale. Coordinates are ignored.moderation: turns on the engines' strict safe search.
Some fields are absent. entities and notices are returned only when the deployment forwards to Exa. score and highlightScores are returned only by /findSimilar, as with Exa.
/findSimilar is a search, not a nearest-neighbour lookup. Helix fetches the URL, has an LLM describe it in one line, searches for that description and scores the results with an embedding model. Quality is lower than Exa's. Exa itself has deprecated the endpoint and recommends describing the page in a /search query.
These return FEATURE_DISABLED:
compliance(for examplehipaa): compliance is a property of the deployment, not a request option.contents.snapshotAsOfandcontents.crawledBeforeDate: Helix doesn't keep historical snapshots.highlights.dynamicandhighlights.verbosity: Exa beta features.- Search
typevalues other than those listed under Search types. - LLM features, when the operator hasn't configured a search LLM:
summary,outputSchema,deepanddeep-litesearch,/answerand/findSimilar. - Embedding features, when the operator hasn't configured an embeddings model:
highlightsand/findSimilar.
Private and internal URLs are blocked. Helix fetches only public internet addresses. A URL that resolves, or redirects, to a loopback, private-network, link-local or cloud metadata address fails with BLOCKED_DESTINATION (on /findSimilar, the source URL fails the request with a 400 that names the tag). Operators of self-hosted deployments can allow private networks; see Configure web search.
Not implemented: Exa's /agent/runs, research, monitors, batches and websets APIs.