The official Python SDK for Exa, the web search API for AI.
pip install exa-pyRequires Python 3.9+
from exa_py import Exa
exa = Exa(api_key="your-api-key")
# Search the web
results = exa.search(
"blog post about artificial intelligence",
type="auto",
contents={"highlights": True}
)
# Ask a question
response = exa.answer("What is the capital of France?")results = exa.search(
"machine learning startups",
contents={"highlights": True}
)results = exa.search(
"climate tech news",
num_results=20,
start_published_date="2024-01-01",
include_domains=["techcrunch.com", "wired.com"],
contents={"highlights": True}
)results = exa.search(
"What are the latest battery breakthroughs?",
type="auto",
system_prompt="Prefer official sources and avoid duplicate results",
output_schema={
"type": "object",
"properties": {
"summary": {"type": "string"},
"key_companies": {"type": "array", "items": {"type": "string"}},
},
"required": ["summary", "key_companies"],
},
)
print(results.output.content if results.output else None)for chunk in exa.stream_search(
"What are the latest battery breakthroughs?",
type="auto",
output_schema={"type": "text"},
):
if chunk.content:
print(chunk.content, end="", flush=True)Search output_schema modes:
{"type": "text", "description": "..."}: return plain text inoutput.content{"type": "object", ...}: return structured JSON inoutput.content
system_prompt and output_schema are supported on every search type.
Search streaming is available via stream_search(...), which yields OpenAI-style chat completion chunks.
A search without output_schema has no text to stream. Its stream yields one chunk with the results as citations.
For type: "object", search currently enforces:
- max nesting depth:
2 - max total properties:
10
Deep search variants that also support additional_queries:
deep-litedeepdeep-reasoning
Use extra_body for per-call search body overrides. Every other method takes it too; see Request options.
result = exa.search("AI research", extra_body={"futureField": True})results = exa.get_contents(
["https://exa.ai/docs"],
text=True
)results = exa.get_contents(
["https://arxiv.org/abs/2303.08774"],
highlights=True
)response = exa.answer("What caused the 2008 financial crisis?")
print(response.answer)stream = exa.stream_answer("Explain quantum computing")
for chunk in stream:
print(chunk, end="", flush=True)
# The cost arrives at the end of the stream.
print(stream.cost_dollars)Use Exa as a web_search tool in an OpenAI or Anthropic loop. Call web_search() with no arguments to get Exa's recommended settings for agentic search (type="auto" and contents={"highlights": True}).
from exa_py import Exa
from openai import OpenAI
exa = Exa()
openai_client = OpenAI()
messages = [{"role": "user", "content": "What's the latest on AI chips?"}]
completion = openai_client.chat.completions.create(
model="gpt-5.6",
messages=messages,
tools=[exa.openai.web_search()],
)
message = completion.choices[0].message
messages.append(message)
messages += exa.openai.handle_tool_calls(message)import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=messages,
tools=[exa.anthropic.web_search()],
)Pass name (and optionally description) to rename the tool. Anthropic requires tool names to be unique, so a custom name lets the Exa tool run alongside Anthropic's built-in web_search_20250305 tool:
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=messages,
tools=[
exa.anthropic.web_search(name="exa_web_search"),
{"type": "web_search_20250305", "name": "web_search", "max_uses": 5},
],
)get_contents is available in the same namespaces and lets the model read pages it already has URLs for. It takes a list of URLs and accepts every Exa.get_contents option:
completion = openai_client.chat.completions.create(
model="gpt-5.6",
messages=messages,
tools=[
exa.openai.web_search(),
exa.openai.get_contents(summary=True, max_age_hours=1),
],
)For the OpenAI Responses API, use exa.openai.responses.web_search() and the same handle_tool_calls helper. The handlers answer every tool call: calls naming a tool they can't resolve get an Error: unknown tool "<name>" output instead of being dropped, so follow-up requests stay valid. If you run other tools alongside Exa's, replace those error outputs with your own results before the next request.
The Agent API is available without a beta header.
run = exa.agent.runs.create(
query="Find engineering leaders at AI infrastructure companies that raised a Series A or B in the last 6 months.",
output_schema={
"type": "object",
"properties": {
"people": {
"type": "array",
"maxItems": 10,
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"contact_email": {"type": "string", "format": "email"},
"linkedin_url": {"type": "string", "format": "uri"},
},
"required": ["name", "linkedin_url"],
},
}
},
"required": ["people"],
},
effort="auto",
)
run = exa.agent.runs.poll_until_finished(run.id)
print(run.output.structured if run.output else None)For Agent Ultra, set effort="ultra":
from exa_py import Exa
exa = Exa()
run = exa.agent.runs.create(
query="Find all companies building browser automation tools in the United States.",
effort="ultra",
budget={"maxCostDollars": 10},
)Agent Monitors use the beta namespace and require the AGENT_MONITORS_BETA_HEADER beta identifier (agent-monitors-2026-08-04).
An Agent Monitor keeps a table of entities × fields fresh on a cadence.
from exa_py.agent import AGENT_MONITORS_BETA_HEADER
betas = [AGENT_MONITORS_BETA_HEADER]
# Create a monitor. Creation is async: it returns with status "creating"
# and becomes "active" once the first refresh completes.
monitor = exa.beta.agent.monitors.create(
betas=betas,
cadence="7d",
entities=[
{"name": "Acme Corp", "domain": "acme.com"},
{"name": "Globex", "domain": "globex.com"},
],
fields=[
{"name": "funding", "description": "New funding rounds"},
{"name": "ceo", "description": "The company's current CEO"},
],
idempotency_key="my-monitor-1", # safe retries: same key returns the same monitor
)
# Page the monitor's current entities and their contents.
for view in exa.beta.agent.monitors.entities.list_all(monitor.id, betas=betas):
print(view.entity.name, view.contents)
# Follow the content change feed (resume later from the page's next_cursor).
changes = exa.beta.agent.monitors.changes.list(
monitor.id,
betas=betas,
since="2026-01-01T00:00:00Z",
)
# One-shot backtest of a past news window — no monitor persists.
backtest = exa.beta.agent.monitors.backtests.create_and_wait(
betas=betas,
entities=[{"name": "Acme Corp", "domain": "acme.com"}],
fields=[{"name": "funding", "description": "New funding rounds"}],
start_time="2026-01-01T00:00:00Z",
end_time="2026-01-08T00:00:00Z",
)
print(backtest.data)
# Add entities, inspect refresh progress, clean up.
exa.beta.agent.monitors.entities.add(
monitor.id,
betas=betas,
entities=[{"name": "Initech", "domain": "initech.com"}],
)
current = exa.beta.agent.monitors.get(monitor.id, betas=betas)
print(current.status, current.refresh, current.usage)
exa.beta.agent.monitors.delete(monitor.id, betas=betas)from exa_py import AsyncExa
exa = AsyncExa(api_key="your-api-key")
results = await exa.search("async search example", contents={"highlights": True})Every API method takes three keyword arguments on top of its own, on Exa and AsyncExa alike:
| Argument | Effect |
|---|---|
extra_body |
Extra fields merged over the JSON request body |
extra_headers |
Extra request headers |
extra_query |
Extra query parameters |
# Send a field the SDK does not type yet.
exa.get_contents("https://example.com", text=True, extra_body={"futureField": True})
exa.websets.list(
limit=10,
extra_query={"futureParam": "value"},
extra_headers={"X-Request-Source": "nightly"},
)They add to the request the method builds. On a name the method also sets, your value wins.
extra_bodyis sent withPOSTandPATCHrequests. Other requests ignore it; useextra_querythere.extra_queryskips a parameter set toNoneand sendsTrueandFalseastrueandfalse, from both clients.- A header the SDK sets, such as
User-Agent, is replaced whatever its case. A header set toNoneis skipped. - The method you call decides whether the response streams.
streamis never taken fromextra_bodyorextra_query, and an extraAcceptheader neither starts a stream nor replaces theAcceptof a streaming method: call the streaming method instead. - Helpers that make several requests (
list_all,get_all,poll_until_finished,create_and_wait,wait_until_idle) apply them to each one, socreate_and_waitsendsextra_bodywith the create request and not with its polls. Acursorinextra_querypicks the first page oflist_allandget_all; the helper advances from there. - A CSV import's upload goes to a presigned URL and receives none of the three.
- The LLM tool helpers (
exa.tools,exa.openai,exa.anthropic) pass the options andtimeoutgiven when you create a tool to every request it makes, for exampleexa.openai.web_search(timeout=10, extra_headers={"X-Request-Source": "agent"}).run,executeand thehandle_tool_calls/handle_tool_usehelpers take none per call.
The sync and async clients default to a 600-second network inactivity timeout (not a wall-clock deadline). Set a client-wide value or override it for a search, contents, answer, or streaming call; use None at construction to disable it. A per-call None inherits the client's value.
from exa_py import Exa
exa = Exa(api_key="your-api-key", timeout=30)
results = exa.search("latest AI developments", timeout=5)A request that exceeds its timeout raises ExaTimeoutError, from the sync and the async client alike:
from exa_py import Exa, ExaTimeoutError
exa = Exa(api_key="your-api-key")
try:
results = exa.search("latest AI developments", timeout=5)
except ExaTimeoutError:
results = NoneThe error is also an instance of the exception the HTTP library raised, so handlers written for those keep working:
AsyncExa: anhttpx.TimeoutException.Exa: arequests.exceptions.Timeoutwhile connecting or waiting for the response to start, and arequests.exceptions.ConnectionErrorwhen the response stalls after it started, as Requests reports it.
With AsyncExa, cancel an in-flight operation by cancelling the task awaiting it:
import asyncio
from exa_py import AsyncExa
exa = AsyncExa(api_key="your-api-key")
task = asyncio.create_task(exa.search("latest AI developments"))
task.cancel()When stopping an async search/answer stream early, call await stream.aclose() to release its HTTP connection; cancellation while awaiting the next chunk also closes it. The sync client cannot cancel a blocking read from another thread; the timeout bounds each period of network inactivity. Both constructors and per-call overrides require a positive finite timeout when not None.
The API can return warnings about a request, such as a field it ignores. At the time of writing, search responses carry them, including stream_search. A warning never changes the status code or the results. It is for whoever maintains the code that builds the request. Fix the request, and don't pass the warning to a model. The response keeps them in warnings.
The client also issues each one as an ExaWarning through Python's warnings module, attributed to the line of your code that made the call:
app.py:12: ExaWarning: [exa] POST /search IGNORED_FIELD at /startCrawlDate: startCrawlDate is no longer supported and is ignored. Remove it from the request.
Python's default filter shows a given warning once per calling line. Silence them, or route them to logging:
import logging
import warnings
from exa_py import ExaWarning
warnings.filterwarnings("ignore", category=ExaWarning) # silence them
logging.captureWarnings(True) # or send them to the "py.warnings" loggerEach ExaWarning has the attributes warning, endpoint, method and request_id. warning is the ApiWarning, with its code, path and message.
Deprecated options raise a DeprecationWarning before the request is sent, whether you set them as options or through extra_body. At the time of writing, those are start_crawl_date, end_crawl_date, and the highlights options num_sentences and highlights_per_url. Python shows a DeprecationWarning by default only for code run as __main__. Test runners such as pytest show them too.
See the full documentation for all features including websets, filters, and advanced options.