Recipe: Built-in Tools (Web Search / Code Execution)¶
Goal¶
Enable provider built-in tools and combine them with normal prompting or schemas.
Example¶
from doc_processing_toolkit import Agent
from doc_processing_toolkit.schemas import BuiltInToolConfig
agent = Agent(
model="gpt-4o",
instructions="You are a research assistant.",
built_in_tools={
"web_search": BuiltInToolConfig(enabled=True),
"code_execution": BuiltInToolConfig(enabled=True),
},
)
result = await agent.run("Research Fibonacci sequence and implement it in Python.")
print(result.output)
Tool names across providers¶
Built-in tools are requested by a standardized name whatever the provider. Two of them have a different name on Google's API, and the toolkit rewrites those for you:
| You pass | Anthropic | OpenAI | Fireworks | |
|---|---|---|---|---|
web_search |
web_search |
web_search |
google_search_grounding |
— |
url_context |
— | — | google_url_context |
— |
web_fetch |
web_fetch |
— | — | — |
code_execution |
code_execution |
code_execution |
code_execution |
— |
context_editing |
context_editing |
— | — | — |
remote_mcp |
remote_mcp |
remote_mcp |
— | remote_mcp |
tool_search |
tool_search |
tool_search |
— | — |
Only the first two rows are aliases — web_search and google_search_grounding are the same tool
under two names, so passing either works and the toolkit sends the one the provider expects. The rest
are separate tools that simply do not exist everywhere.
That distinction matters most for the two that look interchangeable and are not. web_fetch
(Anthropic) retrieves a URL you name and hands the model its content; url_context (Google) grounds a
prompt in a URL you name. They answer similar questions with different mechanics and different
citation behaviour, so the toolkit will never substitute one for the other silently. What it does
instead is say so — ask for web_fetch on a Gemini model and the tool is dropped with:
Tool 'web_fetch' is not supported by model 'gemini-3.7-flash'.
Supported tools: ['code_execution', 'google_url_context', 'google_search_grounding']
Did you mean 'url_context'?
The suggestion is checked against that model's own row before it is offered, so it never points at a
second tool the model also lacks — an image row advertising only search grounding gets no suggestion
at all, and neither does a provider with no counterpart (Fireworks, which advertises only
remote_mcp). A name copied across providers is understood too: google_url_context handed to
Anthropic is answered with web_fetch. The message is a logger WARNING on the run; it is also the
third element of utils.validate_built_in_tools(...), if you want to surface the hint in your own
UI rather than read it out of a log. utils.nearest_built_in_tool(tool_name, provider, supported)
answers the same question on its own and returns None when there is nothing honest to suggest.
Tool search and deferred tools¶
A model offered fifty tools pays for fifty tool schemas in every request, and on Anthropic those
schemas sit inside the cached system block — so a tool surface that changes between turns silently
destroys the cache read for the whole run. Tool search is the providers' answer: the tools you defer
are sent with defer_loading: true, which keeps their schemas out of the prompt until the model
searches for them by name.
from doc_processing_toolkit import Agent
from doc_processing_toolkit.schemas import BuiltInToolConfig
agent = Agent(
model="claude-haiku-4-5",
tools=[{"type": "mcp_script", "path": "catalog_server.py", "name": "catalog"}],
built_in_tools={"tool_search": BuiltInToolConfig(enabled=True)},
defer_tools=lambda tool_name, server_name: server_name == "catalog",
)
result = await agent.run("Look up SKU 4417.")
print(result.deferred_tools) # the tools that were actually hidden
defer_tools takes either a set of tool names or a predicate called as
predicate(tool_name, server_name), where server_name is the MCP server the tool came from
(None for a tool with no server). A one-argument callable is a TypeError at construction. YAML
takes the list form (defer_tools:), because a predicate is only expressible in Python, and the
agent factory's create_agent / create_agent_session take enable_tool_search,
tool_search_variant and a defer_tools list.
On Anthropic, config={"variant": "bm25"} swaps the regex search tool for the BM25 one — regex is
the default and an unknown variant raises locally, naming both. On OpenAI the hosted search tool has
no variants, so a variant there is ignored with a warning and never reaches the wire; that is
deliberate, so one configuration survives a fallback chain that crosses providers.
Nothing here can make a tool unreachable
If tool search is not enabled, or the model's registry row does not list tool_search, or the
provider has no native tool search at all (Google and Fireworks), every tool loads eagerly,
one warning names why, and the run proceeds. AgentResult.deferred_tools is empty in each of
those cases, which is the signal to go looking for the warning. The tool-search tool itself,
every other built-in tool, and the tool the toolkit adds for tool-based structured output are
never deferred — eligibility is structural, so no predicate can defer them.
Tool search is not separately metered on either provider: its built_in_tool_pricing rows are
explicitly empty, and a run that searches costs the same as the same run without it. A search that
fails is logged at WARNING with its error code and the run continues with the tools already loaded.
A server_tool_use block named tool_search_tool_regex or tool_search_tool_bm25 is reported back
as plain tool_search in built_in_tools_invoked, rather than as a wire name no caller can
configure.
Which models. Fourteen Anthropic rows and thirteen OpenAI rows (gpt-5.4 and later) carry it today.
Ask the registry rather than a list: get_model_info(model)["supported_built_in_tools"] contains
tool_search exactly where it is available. One Anthropic row carries it on live evidence rather
than on the published compatibility table, which omits it: claude-sonnet-5. claude-opus-5-5 was
added the same way, verified live on 2026-09-23 with both the regex and bm25 variants before the
table named it; the table read on 2026-10-02 lists it, and lists claude-sonnet-5-5, which passed the
same live check that day, as did claude-haiku-5-5 on 2026-10-09. Each of the four records its
provenance in a _tool_search_note.
Tool search and output_schema do not compose
No source on either provider states that tool search works alongside a response schema, so
tool_search is deliberately absent from every row's
supports_built_in_tools_and_structured_outputs. Combining them drops the tool with the existing
"cannot be used concurrently" warning rather than sending a request that would 400.
See Prompt Caching for what deferral does to the cached prefix, and Remote MCP Servers for deferring a whole provider-connected server.
Did the tool actually run?¶
Two result fields look similar and mean different things:
| Field | Meaning |
|---|---|
built_in_tools_used |
The built-ins configured on the request. A config echo — it says a tool was offered to the model, never that the model reached for it. |
built_in_tools_invoked |
The subset with observed evidence that it ran this turn. Empty when a configured tool was never invoked. |
result = await agent.run("What is 2 + 2?")
result.built_in_tools_used # ['web_search', 'code_execution'] — both were enabled
result.built_in_tools_invoked # [] — the model just answered
Do not use built_in_tools_used to decide whether a search happened, whether to bill something, or
whether a fallback should fire. It reports the same list on a run that searched ten times and on a
run that searched zero times. built_in_tools_invoked is derived separately at return time, so the
configured list keeps its exact meaning and continues to gate uploads, MCP wiring, and pricing
branches internally.
Evidence differs per provider, because each reports invocation differently:
| Provider | What counts as evidence |
|---|---|
| Anthropic | server_tool_use / mcp_tool_use blocks in the response, a code-execution container that demonstrably ran, and non-zero search / fetch counters |
| OpenAI | search_count > 0 for web_search; observed containers or created files for code_execution; mcp_call output items for remote_mcp |
grounding_metadata for google_search_grounding, url_context_metadata for google_url_context, an executable_code / code_execution_result part for code_execution |
|
| Fireworks | An MCP tool-invocation output item for remote_mcp — the only built-in Fireworks exposes |
The honest per-tool counters (search_count, code_sessions, distinct_urls) remain the finest
signal when you need magnitude rather than a yes/no.
Fireworks built_in_tools_used reports custom/MCP tool names
Fireworks populates the field from a placeholder key that carries the MCP/custom tool names
the run actually called — and only when built_in_tools was supplied at all. So on Fireworks the
field is neither a built-in list nor a config echo. The quirk is preserved deliberately:
built_in_tools_used was not redefined on any provider. Read built_in_tools_invoked there,
which reports ['remote_mcp'] when a remote MCP tool was genuinely invoked.
Structured output + built-in tools¶
from pydantic import BaseModel
from doc_processing_toolkit import Agent
from doc_processing_toolkit.schemas import BuiltInToolConfig
class ResearchSummary(BaseModel):
topic: str
summary: str
agent = Agent(
model="claude-sonnet-4-5",
instructions="Use tools and respond with structured output.",
built_in_tools={"web_search": BuiltInToolConfig(enabled=True)},
)
result = await agent.run("Find current Python release highlights.", output_schema=ResearchSummary)
print(result.output)
Code execution-focused example:
agent = Agent(
model="gpt-4o-mini",
instructions="Use code execution for verification.",
built_in_tools={"code_execution": BuiltInToolConfig(enabled=True)},
)
result = await agent.run("Compute prime numbers up to 30 and summarize the method.")
OpenAI: distinct_urls comes from the searches, not only the citations
A JSON or structured answer carries no url_citation annotations, so reading URLs from the
annotations alone left distinct_urls empty after a search that really ran. Every OpenAI
request that carries web_search now also sends
include=["web_search_call.action.sources"] (added to any include already on the request,
never replacing it), and distinct_urls is the de-duplicated union of the cited URLs and the
URLs each web_search_call searched, cited ones first, with utm_source stripped. A prose answer
can therefore list more URLs than it cites. DPT_OPENAI_WEB_SEARCH_SOURCES=off restores the
previous request (no include entry) and the citations-only list.
OpenAI: code interpreter is billed per container actually used
OpenAI charges per code-interpreter container session. The toolkit counts sessions from the
container_ids observed in code_interpreter_call output items after the response arrives,
rather than counting one session per configured tool. An agent that enables code_execution
but whose model never runs code is therefore billed $0 for the tool.
OpenAI: files the code interpreter writes are downloaded (fixed in 0.5.2)
A file the model saves in the container is cited in its reply (a container_file_citation
annotation), and the toolkit downloads each cited file to code_execution_output_dir (or, by
default, to a fresh ./openai_code_execution_files_<timestamp>_<random>/ per run — see
Output files) and lists it in result.files_created. Until
0.5.2 no file was ever downloaded on OpenAI: the download called the SDK with its two ids in
the wrong order, every entry of downloaded_files reported success: False with a TypeError
in the log, and files_created stayed empty. Both paths are fixed and were checked live: a run
with the tool alone, and a run with local tools as well (the tool loop, which now downloads
through its async client). The cited name is reduced to a single safe path component before
it is written (utils.safe_output_filename, on every provider), so a name such as
../../outside.txt cannot leave the folder; downloaded_files[i]["filename"] is the name written.
Dual built-in tools example:
agent = Agent(
model="claude-sonnet-4-5",
instructions="Research and verify with code when useful.",
built_in_tools={
"web_search": BuiltInToolConfig(enabled=True),
"code_execution": BuiltInToolConfig(enabled=True),
},
)
result = await agent.run("Find and verify the first 10 Fibonacci numbers.")
Anthropic: built-in tools alongside custom tools
On Anthropic, built-in (server) tools execute server-side, and the resulting
server_tool_use / *_tool_result block pairs live inside the assistant
message. When a turn also triggers custom tools (or pause_turn /
max_tokens continuation), the toolkit echoes both halves of every pair
back to the API automatically — Anthropic rejects an unpaired
server_tool_use with a 400 — so built-in and custom tools can be freely
combined in the same run.
Google: built-in tools alongside custom tools (Preview)¶
Gemini 3 text models can now run Google's server-side built-in tools in the same request as your
custom @tool functions and MCP tools. This was previously rejected outright — the toolkit raised
a ValueError whenever built_in_tools and custom tools were both supplied to a Google model.
from doc_processing_toolkit import Agent, tool
from doc_processing_toolkit.schemas import BuiltInToolConfig
@tool
def lookup_internal_price(sku: str) -> float:
"""Return the internal list price for a SKU."""
return {"A-100": 42.50}.get(sku, 0.0)
agent = Agent(
model="gemini-3.5-flash",
instructions="Research publicly, then price internally.",
tools=[lookup_internal_price],
built_in_tools={
"web_search": BuiltInToolConfig(enabled=True),
"code_execution": BuiltInToolConfig(enabled=True),
},
code_execution_output_dir="./gemini_artifacts",
)
result = await agent.run("Find the market price for SKU A-100 and compare it to our list price.")
print(result.built_in_tools_used) # ['google_search_grounding', 'code_execution'] — what was CONFIGURED
print(result.built_in_tools_invoked) # the subset with evidence it actually RAN this turn
built_in_tools_used is a configuration echo
A run where the model answered without searching still reports web_search there. Under
combination that distinction matters most — see Did the tool actually run?
for the evidence each provider exposes.
Which models¶
Combination is gated on the registry flag supports_concurrent_built_in_and_custom_tools, which is
true on every Gemini 3 text row and false on the Gemini 3 image rows and on all Gemini 2.5
models, which still raise a ValueError when both tool kinds are supplied. The flag is the source of
truth and the set grows with each Gemini release — read model_information.json, or call
get_model_info(<model>), rather than relying on a list here.
The same rows also carry supports_built_in_tools_and_structured_outputs, so they take the native
structured-output path (Path A: response_schema in the generation config) instead of the
submit_final_response tool fallback. Both keys are absent-means-no, and a test derives the row set
from the registry so a new Gemini 3 text row that omits either one fails the gate rather than
silently rejecting a feature all its siblings have.
What the toolkit does differently under combination¶
The Gemini API requires a specific request shape when both tool kinds are active, and the toolkit applies it automatically:
- Your custom function declarations and the built-ins go into separate
Toolobjects — one carryingfunction_declarations, another carrying the built-in configuration. tool_config.include_server_side_tool_invocations=Trueis set, so the model's server-side tool calls are circulated back through the conversation astool_call/tool_responseparts. The API's own400names this flag when it is missing.- Function-calling mode is left as you set it.
AUTOworks in this configuration, sotool_choiceis passed through unchanged.
Changed in 0.3.0 — the required construction moved, and the old one fails silently
Earlier releases merged both tool kinds into one Tool object and forced VALIDATED mode,
which is what the API required when combination first shipped. Google changed the contract: the
merged shape is still accepted, but the built-in tool never executes — no error, no
warning, just a run where only your custom tools ran. If you pin an older
doc-processing-toolkit, combined Gemini 3 runs will look successful while silently skipping
the built-in. Verified live on gemini-3.7-flash and gemini-3.5-flash-lite (2026-08-23).
Supported built-ins under combination are web_search (google_search_grounding),
code_execution, and url_context (google_url_context). Anything else is dropped with a warning.
Billing follows the same accounting as the non-combination path: grounded searches are counted per
iteration from each response's grounding_metadata.web_search_queries, and code execution is billed
as one session per run.
Preview caveat: file artifacts under combination are unproven
This limitation was first recorded while the merged-Tool construction meant the built-in never
actually ran, so no executable_code or inline_data parts could come back and
result.files_created was always empty. Under the split-Tool construction those parts do
circulate through the conversation history, and extraction runs on every iteration rather than
only on the final response — so artifacts should now surface. That has not been re-verified
live since the fix, so treat result.files_created as unproven under combination. If you need
the generated files with certainty today, run code execution without custom tools via the
structured-output path, where extraction is verified.
Where artifacts land¶
code_execution_output_dir sets the directory for any built-in tool file output. When it is not
supplied, the toolkit creates a fresh ./google_code_execution_files_<YYYYMMDD_HHMMSS>_<random>/
when the first response of the run holds an artifact (0.5.3) and every later iteration writes into
the same folder, so a run's artifacts land together and a run without artifacts creates no folder.
A later iteration's generated_code_1.py no longer overwrites an earlier one's: it is written as
generated_code_1 (2).py (see Output files).
The extractor recognises four part types: executable_code (saved as .py),
code_execution_result (.txt), images via as_image() (.png), and — new — any other
inline_data blob. Non-image blobs such as CSV, XLSX, PDF, and JSON are now saved under the
blob's display_name when the server supplies one (it usually carries the real filename), or a
MIME-derived name like generated_file_1.csv otherwise. These blobs were previously discarded
silently. Image blobs are still handled by the as_image() branch so nothing is written twice.
Output files: folders and names¶
The same rules apply on OpenAI, Google and Anthropic since 0.5.3:
- The default folder is fresh per run. With no
code_execution_output_dir, a run that produces files writes them to./<provider>_code_execution_files_<YYYYMMDD_HHMMSS>_<random>/in the working directory. The folder is created once the reply cites a file (OpenAI, Anthropic) or a response holds an artifact (Google), before the downloads run, so a run whose downloads all fail leaves an empty folder and a run with no artifact creates none. Before 0.5.3 the folder was stamped to the second, so two runs that started in the same second shared it, and a cited name both produced (output.csv) was silently overwritten with both records reporting success. The random suffix makes sharing impossible; the timestamp keeps the folders sortable, and a glob on<provider>_code_execution_files_*still matches. A folder that cannot be created (a read-only working directory) fails the download records, not the run, on OpenAI; Google's single-request path and Anthropic raise as they always did, and Google's tool-combination loop logs a WARNING and carries on. An explicitcode_execution_output_diris shared across runs by design. - A run never overwrites one of its own files. A name the run already wrote is written as
name (2).ext, thenname (3).ext, … (the file-manager convention), and the record inresult.downloaded_filescarries the name actually written infilename. That covers two cited names that clean to one (a:banda?bboth becomea_b), a model that saves the same name twice, and Google's generated names across the iterations of a tool-combination run. Only the run's own writes count: a file an earlier run left in a reused explicit folder is overwritten as before, so a client that reruns a job into its own folder still findsreport.csvwhere it looks. A write that fails gives its name back, so the next file of that name is not numbered past one that does not exist; the failed record keeps the name it attempted, withsuccessfalse and nopath, so key successful records bypathand failed ones byfile_id(OpenAI and Claude; a failed Google record carries neither), never byfilename. One INFO line names the rename; the sanitiser's WARNING is reserved for a name that was unsafe. - OpenAI citations are deduplicated. A file the reply cites twice is downloaded once and listed
once.
result.files_createdlists the paths written; the provider'sfiles_createdcount is one per distinct cited file, successful or not, andfiles_downloadedthe files written. - Anthropic downloads the files its container produced the same way: to
code_execution_output_dir, or to a fresh./anthropic_code_execution_files_<timestamp>_<random>/— on the single-request path. Claude's SDK streams a download chunk by chunk into the file, so a download that fails midway is removed when the file was new to the folder and the record names what was attempted; an earlier run's file under that name is overwritten in place as before (a failure midway leaves it truncated, as it did before 0.5.3). The tool-calling path (local tools present) downloads the same way since 0.5.3; until then it downloaded only whencode_execution_output_dirorskillswas set, so a run withcode_executionalone and the default folder got no files there.
Anthropic web_fetch and programmatic tool calling¶
web_fetch retrieves the contents of a URL. Configure it like any other built-in tool, optionally
constraining which hosts it may reach:
agent = Agent(
model="claude-sonnet-4-5",
built_in_tools={
"web_fetch": BuiltInToolConfig(
enabled=True,
config={
"allowed_domains": ["docs.python.org"],
"max_content_tokens": 20000,
"citations": {"enabled": True},
},
),
},
)
allowed_callers and older Claude models¶
Anthropic's web_fetch defaults to allowing both direct model invocation and invocation from
inside code execution (programmatic tool calling). Models that do not support programmatic tool
calling reject that default with a 400, so the toolkit sets allowed_callers=["direct"]
automatically for them and logs the substitution at INFO.
Which models need it is registry data, not a hardcoded list — the flag is
descriptions.<model>.supports_programmatic_tool_calling in model_information.json. It is
currently false for claude-haiku-4-5 only; every other Claude model in the registry supports
it, is flagged true and keeps Anthropic's default.
You can always set the field yourself, and an explicit value is never overridden:
BuiltInToolConfig(
enabled=True,
config={"allowed_callers": ["direct"]},
)
Reusing Anthropic code execution containers¶
Anthropic code execution runs inside a container whose filesystem and REPL state
persist for up to 30 days. By reusing the container id across runs, files uploaded
via tool_files in one run remain available in subsequent runs without re-uploading,
and variables defined earlier remain in scope. Container reuse is Anthropic-only;
other providers silently ignore the settings below.
Where container_id comes from on streaming runs
Code execution is GA and needs no beta header, but the plain /v1/messages streaming
response omits the top-level container object — only the beta surface emits it, on
message_stop. So whenever code execution is enabled the toolkit opts the request into the
code-execution-2025-08-25 beta, which routes the run onto that surface and makes
result.container_id populated for streams as well as non-streaming runs. Without it,
container_id came back None from a streamed code-execution run and there was nothing to
reuse.
Static reuse via BuiltInToolConfig.config¶
Pass the prior run's container_id when constructing the Agent:
agent = Agent(
model="claude-sonnet-4-5",
instructions="Continue working in the same sandbox.",
built_in_tools={
"code_execution": BuiltInToolConfig(
enabled=True,
config={"container_id": "container_abc123"},
),
},
)
result = await agent.run("List files in the working directory.")
Per-run override via anthropic_container_id¶
Reuse a container for a specific run without rebuilding the Agent:
first = await agent.run(
"Save a CSV file called data.csv with three rows.",
tool_files=[...],
)
print(first.container_id) # capture for reuse
second = await agent.run(
"Open data.csv and compute the column sums.",
anthropic_container_id=first.container_id,
)
Auto-enables the code_execution tool when an anthropic_container_id is supplied
but the Agent didn't declare the tool — user intent is explicit.
Session auto-reuse¶
Opt in at session creation to have the toolkit capture the container id from the first run and re-inject it into every subsequent run and stream in the session:
async with agent.session(reuse_anthropic_container=True) as s:
await s.run("Upload and inspect sales.xlsx.", tool_files=["sales.xlsx"])
await s.run("Now pivot it by region and export pivot.csv.")
await s.run("Download pivot.csv.")
Non-Anthropic agents log a warning and behave as if reuse_anthropic_container=False.
Upload preservation under container reuse¶
Whenever a container-reuse signal is present — a runtime anthropic_container_id,
a BuiltInToolConfig(code_execution, config={"container_id": ...}) binding, or an
AgentSession(reuse_anthropic_container=True) — the toolkit automatically tells
the Anthropic provider to preserve (i.e. not delete) uploaded tool-file ids
and uploaded custom-skill ids at the end of the run. The same ids stay attached
to the container on Anthropic's side, so downstream runs that resume the container
continue to see the uploaded files and skills without re-uploading.
Without a reuse signal, the toolkit retains its existing behaviour of deleting tool-file and custom-skill ids once the call completes (keeps the Files API workspace tidy for one-shot usage).
Note on filename preservation: when each entry in tool_files carries a filename
(directly via dict input, derived from a local file path, or supplied as a filename
kwarg on raw bytes), the toolkit forwards it to the provider's Files API so the
original name survives into the code-execution container. Anthropic and OpenAI
receive a (filename, bytes, content_type) multipart tuple; Google uses the value
as the Files API display_name. Without a filename, providers fall back to a
generic name (upload for Anthropic, similar defaults for OpenAI, and a sequential
tool_file_{i} placeholder for Google), which would cause distinct uploads to
collide on the same in-container path within a single run.
Note on same-filename uploads: if you upload a file with the same name to a
reused container (e.g. a fresh animals.csv on a follow-up turn), Anthropic
places the new bytes at the same path inside the container filesystem,
overwriting the prior copy. There is no client-side collision — each upload
gets its own Files API id, but the in-container path gets the latest content.
What to validate¶
- built-in tool path runs without provider mismatch
- output reflects tool-assisted result
- schema output is validated when
output_schemais provided
Test sources¶
tests/e2e/test_agent_e2e.py(test_built_in_tools_with_thinking,test_built_in_tools_with_structured_output,test_quad_combination_thinking_builtin_custom_tools_schema)tests/integration/test_combination_openai.py(web search/code execution combinations)tests/integration/test_anthropic_function_calling.py(built-in tool combinations)