web-search

Web search for Hot agents and apps. Tavily, Brave, Exa, and Firecrawl sit behind one normalized search(). You can pick a provider, fall back to the next on failure, and filter by freshness and domain, and each provider's native API is still there when you need more.

Installation

Add this to the deps in your hot.hot file:

"hot.dev/web-search": "0.1.0"

Setup

Set a key for any provider you have. ::web-search/search uses the first configured one (in the order below) unless you choose another.

Context VariableProvider
tavily.api.keyTavily (tvly-...), search built for LLM agents, with answers and extract
brave.api.keyBrave Search API subscription token
exa.api.keyExa, neural search with page contents and find-similar
firecrawl.api.keyFirecrawl search (fc-...), the same key the firecrawl package uses
web-search.providerOptional default provider: tavily, brave, exa, or firecrawl

Keys can also be passed per call; see Per-call credentials.

Quick start

// Normalized: Vec<{title, url, content, score}>, best first
results ::web-search/search("PostgreSQL 18 release notes", 5)
map(results, (r) { `${r.title} - ${r.url}` })

// Choose the provider, fall back to the others on failure, and filter
recent ::web-search/search("hot.dev release notes", 5, {
  provider: "brave",
  fallback: true,
  freshness: "week",
  include-domains: ["hot.dev"],
  exclude-domains: ["example.com"]
})

search(query), search(query, max-results), search(query, options), and search(query, max-results, options) return Vec<::web-search/SearchResult>:

FieldMeaning
titleResult title (HTML stripped)
urlResult URL
contentPlain-text snippet: Tavily's content, Brave's description (markup stripped, entities decoded), Exa's page text (up to 1,000 characters), or Firecrawl's description
scoreRelevance, 0-1, when the provider reports one (Tavily). Brave and Firecrawl have none, and current Exa responses usually have none
OptionMeaning
provider"tavily", "brave", "exa", or "firecrawl". Defaults to web-search.provider, then the first configured provider
fallbacktrue to try the other configured providers, in order, when one fails
freshness"day", "week", "month", or "year"
include-domains / exclude-domainsVecs of hostnames
max-resultsUsed by search(query, options). Default 10
tavily-api-key, brave-api-key, exa-api-key, firecrawl-api-keyThe provider's key for this call. See Per-call credentials

How each option maps to the providers:

TavilyBraveExaFirecrawl
max results (clamped)max_results 1-20count 1-20numResults 1-100limit 1-100
freshnesstime_rangefreshness pd/pw/pm/pystartPublishedDate (now minus 1/7/31/365 days)tbs qdr:d/w/m/y
domainsinclude_domains / exclude_domainssite: operators in the query (OR between includes, NOT site: per exclude)includeDomains / excludeDomainsincludeDomains / excludeDomains

Errors come back as an Err. With no provider configured, or with the chosen provider unconfigured or unknown, the Err carries a message naming what to set. A failed provider call carries ::web-search::http/HttpError (provider, status, a readable message, body). When fallback runs out of providers, the Err carries {message, errors: [{provider, error}]}.

Provider APIs

The native functions take each provider's own option names and return its raw response:

out ::web-search::tavily/search("qdrant vs pinecone", {include_answer: true, max_results: 5})
out.answer

pages ::web-search::tavily/extract(["https://example.com"], {format: "markdown"})
brave ::web-search::brave/search("rust jit compiler", {count: 5, freshness: "pm"})
exa ::web-search::exa/search("startups building sql runtimes", {numResults: 5, contents: {text: {maxCharacters: 2000}}})
similar ::web-search::exa/find-similar("https://hot.dev", {numResults: 5})
text ::web-search::exa/get-contents(["https://hot.dev"])
fc ::web-search::firecrawl/search("firecrawl changelog", {limit: 3})
ModuleFunctions
::web-search::tavilysearch(query, options?) (POST /search), extract(urls, options?) (POST /extract, up to 20 URLs)
::web-search::bravesearch(query, options?) (GET /web/search)
::web-search::exasearch(query, options?), find-similar(url, options?), get-contents(urls, options?)
::web-search::firecrawlsearch(query, options?) (POST /v2/search). For scraping and crawling, use the firecrawl package

Per-call credentials

Pass a provider's key in the options of search, so one run can act for many tenants and parallel flows don't share credentials. Credentials in the options are masked in call records, and nothing from them is written to ctx.

KeyProvider
tavily-api-keyTavily, instead of tavily.api.key
brave-api-keyBrave, instead of brave.api.key
exa-api-keyExa, instead of exa.api.key
firecrawl-api-keyFirecrawl, instead of firecrawl.api.key
results ::web-search/search("hot.dev release notes", 5, {
  provider: "brave",
  fallback: true,
  brave-api-key: "tenant-b-token",
  tavily-api-key: "tvly-tenant-b"
})

With any of these keys in the call, only those providers are candidates. Context keys are not used for that call: fallback tries only the providers the call passed a key for, web-search.provider applies only when it is one of them, and a provider without a key in the call is an Err. So a tenant's search never runs on the project's account.

The native provider functions take the same keys in a trailing opts Map. opts comes last; where a function already takes options, pass {} for those.

tenant {tavily-api-key: "tvly-tenant-b"}
out ::web-search::tavily/search("qdrant vs pinecone", {max_results: 5}, tenant)

A key that is present but blank or null is an error naming the key, never a fallback to context. ::web-search::api/resolve(opts) is the one place this is decided.

Notes and limits

  • Brave domain filters use search operators. Brave documents its operators as experimental, so site:/NOT site: filtering is best-effort. Brave also caps offset at 9 pages of 20.
  • Exa content costs. The normalized search asks Exa for page text capped at 1,000 characters per result, which Exa bills as contents. Call ::web-search::exa/search directly to choose highlights or summaries instead.
  • Fallback is opt-in. Without fallback: true, a provider failure is returned as is, so you never silently get results from a provider you did not choose.
  • Free tiers. Tavily, Exa, and Firecrawl have free tiers or starter credits. Brave replaced its free plan in 2026 with a monthly credit that needs a card on file. None has a sandbox, so the integration test makes one small live search per configured provider.

License

Apache-2.0 - see LICENSE