HelixML

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/websearch

Don'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.0
import 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.0
import 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?"
CommandFlags
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.

EndpointExa referenceWhat it does
/searchSearchSearch the web. Optionally fetch contents for each result, or synthesise an outputSchema answer from them.
/contentsGet contentsText, highlights, summaries, links and subpages for up to 100 URLs or result ids.
/findSimilarFind similarPages similar to a URL. Deprecated by Exa; see Differences from Exa.
/answerAnswerSearch, 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

typeBehaviour
auto (default)One query to the configured search engines. instant and fast are treated as auto.
liteLike auto, but rejects the fields Exa rejects for lite (crawl dates, text filters, outputSchema, stream, summaries, extras, freshness options and subpages).
deep-lite, deepAn 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):

maxAgeHoursBehaviour
omittedUse the cached copy if there is one, however old; otherwise fetch.
-1Cache only. A page that isn't cached fails with SOURCE_NOT_AVAILABLE.
0Always fetch.
1 to 720Fetch 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"
  }
]
CodeMeaningExamples
IGNORED_FIELDThe 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_FIELDThe 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_RESULTSSome 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" }
StatustagWhen
400INVALID_REQUESTThe 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).
400FEATURE_DISABLEDA 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.
402NO_MORE_CREDITSYour organisation's credit balance can't cover the request.
404NOT_FOUND/answer, or a /search with outputSchema, found no results to answer from.
502UPSTREAM_ERRORNone of the search results could be fetched to answer from.
503UPSTREAM_ERROREvery search engine query failed.
500INTERNAL_ERRORAnything 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 } }
]
TagMeaning
CRAWL_NOT_FOUNDThe site answered 404 or 410.
CRAWL_TIMEOUTThe fetch exceeded livecrawlTimeout.
CRAWL_HTTP_ERRORAnother HTTP error status, or too many redirects.
SOURCE_NOT_AVAILABLEThe page couldn't be reached, or maxAgeHours: -1 was set and the page isn't cached.
BLOCKED_DESTINATIONThe URL, or a redirect it led to, points at a private or internal address.
UNSUPPORTED_DOCUMENTThe 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.

ItemPrice (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: news uses the engines' news search. The other categories add a keyword to the query (people adds "linkedin", financial report adds "annual report 10-K") and return an APPROXIMATED_FIELD warning.
  • includeDomains / excludeDomains: up to 10 domains are sent to the engines as site: operators, and every result is then filtered exactly. With more domains, filtering happens only after searching, so you may get fewer results than numResults.
  • 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 example hipaa): compliance is a property of the deployment, not a request option.
  • contents.snapshotAsOf and contents.crawledBeforeDate: Helix doesn't keep historical snapshots.
  • highlights.dynamic and highlights.verbosity: Exa beta features.
  • Search type values other than those listed under Search types.
  • LLM features, when the operator hasn't configured a search LLM: summary, outputSchema, deep and deep-lite search, /answer and /findSimilar.
  • Embedding features, when the operator hasn't configured an embeddings model: highlights and /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.