Exa Search
ExaSearch gives an agent web research tools backed by the
Exa search API: search that returns the most relevant
excerpts from each hit (with an optional synthesized text summary), full-page
retrieval for digging into a specific URL, and opt-in deep search that
synthesizes a cited answer in one call. The separate ExaAgent capability
delegates long-running research to the Exa Agent API as deferred tool calls.
Search tools that return only titles and snippets force a second round of fetching before the agent can judge a source, while search tools that return full page text flood the context with pages the agent will discard. Wiring a search API together with a page fetcher, budgeting what each tool returns, and prompting the agent to research methodically is boilerplate every research agent reinvents.
ExaSearch bundles that plumbing into a single
capability: the research tools, per-tool
output budgets, and short research guidance in the system prompt.
Install the exa extra and set the EXA_API_KEY environment variable (create
a key at https://dashboard.exa.ai):
uv add "pydantic-ai-harness[exa]"
Then pass ExaSearch to an Agent via the capabilities parameter:
from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaSearch
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaSearch()])
result = agent.run_sync('What changed in the latest stable Python release?')
print(result.output)
ExaSearch contributes these tools to the agent:
| Tool | Purpose |
|---|---|
web_search | Search the web and return the top num_results pages, each with title, URL, and its most relevant excerpts. |
get_page | Retrieve the full text of one specific URL — a promising web_search hit, or a URL the user provided. |
deep_search | Run Exa’s multi-step deep search and return a synthesized, cited answer. Opt-in via include_deep_search=True. |
exa_agent | Delegate a research task to an asynchronous Exa agent run. Provided by the separate ExaAgent capability. |
web_search returns short excerpts (Exa highlights) rather than full page
text, following Exa’s own guidance for agents,
so surveying several sources stays cheap; the agent reads a chosen page with
get_page.
get_page text is capped at max_text_chars characters, keeping the head
(a page’s lead carries the substance). One character of headroom above the cap
is requested from Exa, so when a page exceeds the cap the output ends with a
[... page text truncated at N characters] marker; at the API ceiling of
10,000 characters no headroom exists, so the marker cannot appear there. The
result count is bounded the same way: num_results is requested from Exa and
re-applied to the response.
A URL or question that returns no content, a rate limit, or a transient API or
network failure surfaces to the model as a
ModelRetry rather than a
hard error: the run continues and the model can correct the URL, rephrase, or
try again. Authentication failures (401/403) are configuration errors and
propagate.
deep_search calls Exa search with type='deep' and a plain-text output
schema: Exa expands the question into multiple queries, searches, and returns
an answer grounded in citations — all in one tool call, with the cited
sources listed under the answer. Each call invests more time and search depth
than web_search (Exa’s research-grade mode), and the model decides when to
invoke tools, so the tool is off by default — enable it explicitly:
from pydantic_ai_harness.exa import ExaSearch
ExaSearch(include_deep_search=True)
When enabled, the capability’s instructions tell the model to treat it as an
escalation from web_search, not a replacement. The synthesized answer is
returned in full (it is Exa-generated and inherently bounded); max_text_chars
only applies to get_page.
Set text_summary to have every web_search call also request Exa’s
plain-text output schema, so the response carries a short summary synthesized
from the results for question-style queries. Pass True for an unconstrained
summary, or a string describing the desired format (sent as the schema’s
description):
from pydantic_ai_harness.exa import ExaSearch
ExaSearch(text_summary='One concise sentence with the requested facts.')
The tool’s return shape is unchanged and backward compatible: the result list
is returned as before, and when Exa returns a summary it is prepended as a
Summary: line.
Every tool returns a
ToolReturn:
return_value carries the readable text the model sees (unchanged from
previous releases, including the Sources: blocks), and metadata carries
the sources as structured ExaSource records ({'url': ..., 'title': ...})
under the 'sources' key. Metadata is never sent to the model; the
application reads it from the ToolReturnPart in the message history, so
rendering citations needs no text parsing:
from pydantic_ai.messages import ModelRequest, ToolReturnPart
for message in result.all_messages():
if isinstance(message, ModelRequest):
for part in message.parts:
if isinstance(part, ToolReturnPart) and part.metadata is not None:
for source in part.metadata.get('sources', []):
print(source['url'], source['title'])
exa_agent results additionally carry the Exa run ID in metadata under
RUN_ID_METADATA_KEY.
ExaSearch contributes short research guidance to the system prompt: search
wide with web_search first, read the most promising pages in full with
get_page before drawing conclusions, prefer primary sources, and cite the
URLs relied on. With include_deep_search=True, the guidance also covers when
to escalate to deep_search. Set guidance to replace the default text, or
to '' to contribute no instructions at all.
Every field of ExaSearch with its default:
from pydantic_ai_harness.exa import ExaSearch
ExaSearch(
num_results=5, # results per web_search call (1 to 100)
max_text_chars=10_000, # get_page text cap, in characters (1 to 10,000)
text_summary=False, # web_search also returns a synthesized text summary
include_deep_search=False, # also expose the deep_search tool
include_domains=[], # only search these domains (allowlist)
exclude_domains=[], # never search these domains (denylist)
guidance=None, # None = default instructions, '' = none, str = custom
client=None, # ExaClient -- None builds exa_py.AsyncExa from EXA_API_KEY
)
include_domains and exclude_domains apply to web_search and deep_search,
and are mutually exclusive — set one, not both. Out-of-range limits and
setting both domain lists raise at construction.
The Exa Agent API runs open-ended research tasks
asynchronously: a run is created, moves through queued -> running, and
reaches a terminal status (completed, failed, or cancelled) after up to
an hour. The separate ExaAgent capability maps that lifecycle onto Pydantic
AI’s deferred tool calls: its
exa_agent tool creates the run and defers, carrying the Exa run ID in the
deferred call’s metadata.
from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaAgent
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaAgent()])
By default (execution='inline') the capability resolves its own deferred
calls within the agent run by polling the Exa run to completion, so the tool
behaves like a regular (if slow) tool. With execution='external' the calls
bubble up as DeferredToolRequests output for the host application to resolve
out of band — including from a different process, since the Exa run ID
survives in the request metadata under RUN_ID_METADATA_KEY. The agent’s
output_type must include DeferredToolRequests, otherwise the run raises
instead of returning the deferred requests:
from pydantic_ai import Agent
from pydantic_ai.tools import DeferredToolRequests
from pydantic_ai_harness.exa import ExaAgent
agent = Agent(
'anthropic:claude-sonnet-4-6',
output_type=[str, DeferredToolRequests],
capabilities=[ExaAgent(execution='external')],
)
Render a finished run with the agent_run_result helper, passing the same
output_schema the capability was constructed with so external resolution
applies the same validation and produces the same tool result shape as inline
execution, then feed the results and the original message history back into
the agent to resume the deferred run:
from pydantic_ai.tools import DeferredToolResults
from pydantic_ai_harness.exa import RUN_ID_METADATA_KEY, agent_run_result
async def resolve(requests, runs, output_schema=None): # e.g. in a worker process
results = DeferredToolResults()
for call in requests.calls:
run_id = requests.metadata[call.tool_call_id][RUN_ID_METADATA_KEY]
run = await runs.poll_until_finished(run_id)
results.calls[call.tool_call_id] = agent_run_result(run, output_schema=output_schema)
return results
async def resume(agent, messages, results):
return await agent.run(message_history=messages, deferred_tool_results=results)
Every field of ExaAgent with its default:
from pydantic_ai_harness.exa import ExaAgent
ExaAgent(
effort=None, # 'low' | 'medium' | 'high' | 'xhigh' | 'auto' -- None = API default
execution='inline', # 'inline' polls to completion; 'external' bubbles DeferredToolRequests
output_schema=None, # BaseModel class or dict schema for structured output
system_prompt=None, # forwarded to the Exa agent run
poll_interval=1000, # ms between polls when resolving inline
timeout_ms=3_600_000, # ms to wait for a run when resolving inline
guidance=None, # None = default instructions, '' = none, str = custom
runs=None, # ExaAgentRuns -- None builds AsyncExa().agent.runs from EXA_API_KEY
)
An output_schema model class is validated against
the completed run’s structured output (mismatches surface as ModelRetry),
while a dict schema is forwarded without client-side validation and is the
agent-spec form. Terminal failures (failed, cancelled) are returned to the
model as a structured message rather than raised, so the agent can decide how
to proceed. Each result includes the run ID, which the model can pass back as
previous_run_id to ask follow-up questions in the context of a previous run.
Two instances of the same capability register the same tool names, which is an
error. To run several differently configured instances in one agent (for
example one open-web ExaSearch and one pinned to specific domains), wrap the
extra instances in core’s PrefixTools capability, which prefixes their tool
names:
from pydantic_ai import Agent
from pydantic_ai.capabilities import PrefixTools
from pydantic_ai_harness.exa import ExaSearch
agent = Agent(
'anthropic:claude-sonnet-4-6',
capabilities=[
ExaSearch(), # web_search, get_page
PrefixTools(
wrapped=ExaSearch(include_domains=['crunchbase.com'], guidance=''),
prefix='cb',
), # cb_web_search, cb_get_page
],
)
Set guidance='' on the wrapped instance (or replace it with text that tells
the model when to use the prefixed tools), since each instance otherwise
contributes the same default research guidance.
This also works for ExaAgent: it identifies its deferred calls by metadata
it wrote when deferring, not by tool name, so a prefixed exa_agent still
resolves inline, and multiple ExaAgent instances never claim each other’s
calls.
The default client is exa_py.AsyncExa, configured from the EXA_API_KEY
environment variable; when the variable is missing, construction fails with a
setup hint. Pass any object satisfying the ExaClient protocol — the subset of
AsyncExa the toolset calls — to configure authentication or the base URL
explicitly, or to substitute a fake in tests:
from exa_py import AsyncExa
from pydantic_ai_harness.exa import ExaSearch
ExaSearch(client=AsyncExa(api_key='...'))
The API may change between releases while the capability settles; breaking changes ship deprecation warnings where practical.
Pydantic AI core ships a provider-adaptive
WebSearch
capability: on models with a native search tool it uses the provider’s own
search, executed server-side; elsewhere it falls back to a local DuckDuckGo
tool. Reach for it when you want search that follows the model.
Reach for ExaSearch when you want the same search behavior on every model:
one vendor, excerpts with every hit, explicit page retrieval, domain filters,
and opt-in deep search.
One caveat when combining them: on Anthropic models the provider-native search
tool is also named web_search on the wire, so
capabilities=[WebSearch(), ExaSearch()] puts two tools with the same name in
the request. Use one search capability per agent on native-search models, or
force the local fallback with WebSearch(native=False) (its DuckDuckGo tool is
named duckduckgo_search, which does not collide).
Exa also ships an official hosted MCP server at https://mcp.exa.ai/mcp
(exa-labs/exa-mcp-server). By
default it exposes web_search_exa and web_fetch_exa; the full catalog adds
web_search_advanced_exa and an agent-run set (agent_create_run,
agent_wait_for_run, agent_get_run_output, agent_cancel_run).
ExaSearch is the curated, typed path: bounded output, a retry-on-empty
contract, bundled research instructions, and a client seam that makes it
testable offline. The MCP server is how you get Exa’s full catalog with zero
wrapper code, via Pydantic AI core’s MCP capability. Their agent runs are
create-then-poll (agent_create_run returns an ID immediately;
agent_wait_for_run polls it), where deep_search returns the answer in a
single call. The two compose in one capabilities list, and none of the MCP
tool names collide with web_search, get_page, or deep_search:
from pydantic_ai import Agent
from pydantic_ai.capabilities import MCP
from pydantic_ai_harness.exa import ExaSearch
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaSearch(), MCP('https://mcp.exa.ai/mcp')])
ExaSearch works with Pydantic AI’s
agent spec, so you can declare it in a config
file instead of Python:
# agent.yaml
model: anthropic:claude-sonnet-4-6
capabilities:
- ExaSearch:
num_results: 3
include_deep_search: true
- ExaAgent:
effort: low
from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaAgent, ExaSearch
agent = Agent.from_file('agent.yaml', custom_capability_types=[ExaSearch, ExaAgent])
Pass custom_capability_types so the spec loader knows how to instantiate the
capabilities. The client and runs fields are not spec-serializable;
spec-loaded instances always build the default client from EXA_API_KEY. In
specs, output_schema takes the JSON-schema dict form; Pydantic model
classes are only available when constructing the capability in Python.
Bases: AbstractCapability[AgentDepsT]
Web research for agents, backed by the Exa search API.
Adds two tools: web_search, which returns search results with their most
relevant excerpts, and get_page, which retrieves the full text of a
specific URL. Set text_summary to have web_search also return a
synthesized text summary with the results. Set include_deep_search=True
to also expose deep_search, which runs Exa’s multi-step deep search and
returns a synthesized, cited answer in one tool call.
from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaSearch
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaSearch()])
Authentication comes from the EXA_API_KEY environment variable by
default; pass client to configure it explicitly.
Number of results web_search returns per query (1 to 100, the Exa API range).
Type: int Default: 5
Maximum characters of page text get_page returns (1 to 10,000, the Exa API range).
One character of headroom above the cap is requested from Exa so local truncation can detect a longer page and append a truncation marker. At the API ceiling of 10,000 no headroom exists, so the marker cannot fire there.
Type: int Default: 10000
Have web_search also return a synthesized text summary above the results. Off by default.
When enabled, each web_search call requests Exa’s plain-text output
schema, so the response carries a short summary synthesized from the
results in addition to the result list. Pass a string to describe the
desired summary format (it is sent as the schema’s description), or
True for an unconstrained summary. The tool’s return shape is unchanged:
the summary is prepended as a Summary: line when Exa returns one.
Type: bool | str Default: False
Also expose the deep_search tool. Off by default.
Deep search (Exa search type='deep') runs a multi-step agentic search
and synthesizes a cited answer in one call. Each call invests more time
and search depth than web_search (Exa’s research-grade mode), and the
model decides when to invoke tools, so that investment is opt-in rather
than the default.
Type: bool Default: False
If non-empty, search results only come from these domains (allowlist).
Applies to web_search and deep_search. Mutually exclusive with
exclude_domains.
Type: Sequence[str] Default: field(default_factory=(list[str]))
Search results never come from these domains (denylist).
Applies to web_search and deep_search. Mutually exclusive with
include_domains.
Type: Sequence[str] Default: field(default_factory=(list[str]))
Custom research guidance for the system prompt.
Leave as None for the default guidance (which adapts to
include_deep_search), or set '' to contribute no instructions at all.
Type: str | None Default: None
Exa client to use; when None, an exa_py.AsyncExa is built from EXA_API_KEY.
Any object satisfying the ExaClient protocol works: use it to pass an API
key explicitly, point at a different base URL, or substitute a fake in tests.
Type: ExaClient | None Default: None
def __post_init__() -> None
Validate configuration against the Exa API’s documented bounds.
def get_instructions() -> AgentInstructions[AgentDepsT] | None
Static research guidance: search wide, read the promising pages in full, cite URLs.
When include_deep_search is set, the default guidance also covers
when to escalate to deep_search. A non-None guidance replaces the
default; '' disables instructions entirely.
AgentInstructions[AgentDepsT] | None
def get_toolset() -> ExaSearchToolset[AgentDepsT]
Build the toolset providing web_search, get_page, and the optional deep_search tool.
ExaSearchToolset[AgentDepsT]
@classmethod
def from_spec(
cls,
*,
num_results: int = 5,
max_text_chars: int = 10000,
text_summary: bool | str = False,
include_deep_search: bool = False,
include_domains: Sequence[str] = (),
exclude_domains: Sequence[str] = (),
guidance: str | None = None,
) -> ExaSearch[AgentDepsT]
Construct the capability from serializable spec options.
The client field is not spec-serializable, so spec-loaded instances
always build the default exa_py.AsyncExa from EXA_API_KEY.
ExaSearch[AgentDepsT]
Bases: AbstractCapability[AgentDepsT]
Delegation of deep research tasks to the Exa Agent API.
Adds one tool, exa_agent, which creates an asynchronous Exa agent run
(queued -> running -> terminal, up to an hour) and defers the tool call.
By default the capability resolves its own deferred calls inline by
polling the run to completion. With execution='external' the calls
surface as DeferredToolRequests output instead, for the host application
to resolve out of band (the run ID is in the request metadata under
RUN_ID_METADATA_KEY), including across process restarts.
from pydantic_ai import Agent
from pydantic_ai_harness.exa import ExaAgent
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ExaAgent()])
Authentication comes from the EXA_API_KEY environment variable by
default; pass runs to configure it explicitly.
How much work the Exa agent invests per run; None uses the API default.
Type: AgentEffort | None Default: None
How deferred exa_agent calls are resolved.
With 'inline', the capability polls the run to completion during the
agent run. With 'external', calls bubble up as DeferredToolRequests
output for the host application to resolve (see agent_run_result), which
suits durable workers that outlive a single process.
Type: Literal[‘inline’, ‘external’] Default: 'inline'
Structured output schema for the Exa agent’s result. None returns prose.
Accepts a Pydantic model class or a JSON-schema-style dict. A model class is forwarded to the API and a completed run’s structured output is validated against it (a mismatch surfaces as a retry). The dict form skips client-side validation and is the serializable shape used by agent specs.
Type: type[BaseModel] | dict[str, object] | None Default: None
System prompt forwarded to the Exa agent run; None uses the API default.
Type: str | None Default: None
Milliseconds between polls while resolving a run inline.
Type: int Default: 1000
Milliseconds to wait for a run to finish when resolving inline.
Type: int Default: 3600000
Custom delegation guidance for the system prompt.
Leave as None for the default guidance, or set '' to contribute no
instructions at all.
Type: str | None Default: None
Exa Agent runs client; when None, exa_py.AsyncExa().agent.runs is built from EXA_API_KEY.
Any object satisfying the ExaAgentRuns protocol works: use it to pass an
API key explicitly or substitute a fake in tests.
Type: ExaAgentRuns | None Default: None
def get_instructions() -> AgentInstructions[AgentDepsT] | None
Static delegation guidance: when to hand a task to exa_agent, and run ID continuation.
A non-None guidance replaces the default; '' disables
instructions entirely.
AgentInstructions[AgentDepsT] | None
def get_toolset() -> ExaAgentToolset[AgentDepsT]
Build the toolset providing the exa_agent tool.
ExaAgentToolset[AgentDepsT]
@async
def handle_deferred_tool_calls(
ctx: RunContext[AgentDepsT],
*,
requests: DeferredToolRequests,
) -> DeferredToolResults | None
Resolve deferred exa_agent calls inline by polling the Exa run to completion.
With execution='external' all calls are left unresolved, so they
bubble up as DeferredToolRequests output for the host application to
resolve; the Exa run ID is available in
requests.metadatatool_call_id.
Calls are claimed by the instance token in the deferred-call metadata
rather than by tool name, so tool renaming or prefixing wrappers (e.g.
PrefixTools) do not break inline resolution.
@classmethod
def from_spec(
cls,
*,
effort: AgentEffort | None = None,
execution: Literal['inline', 'external'] = 'inline',
output_schema: dict[str, object] | None = None,
system_prompt: str | None = None,
poll_interval: int = 1000,
timeout_ms: int = 3600000,
guidance: str | None = None,
) -> ExaAgent[AgentDepsT]
Construct the capability from serializable spec options.
The runs field is not spec-serializable, so spec-loaded instances
always build the default client from EXA_API_KEY. output_schema
takes the JSON-schema dict form here; Pydantic model classes are only
available when constructing the capability in Python.
ExaAgent[AgentDepsT]
def agent_run_result(
run: AgentRun,
*,
output_schema: type[BaseModel] | dict[str, object] | None = None,
) -> ToolReturn[str]
Render a terminal Exa agent run as the exa_agent tool result.
Use it when resolving externally executed exa_agent calls in a host
application (building DeferredToolResults), so external and inline
execution produce the same result shape. When output_schema is a
Pydantic model class, a completed run’s structured output is validated
against it; a mismatch raises ModelRetry.
The ToolReturn.return_value text is what the model sees; its metadata
carries the run ID under RUN_ID_METADATA_KEY and the citation sources
(ExaSource dicts) for the application to use directly.
Bases: TypedDict
One source behind a tool result, carried in ToolReturn.metadata['sources'].