Agent Development Kit · Build
Web tools: search, read, and look at a page
Three tools ship built: web_search finds pages, fetch_page turns one
into markdown (or hands over a PDF or an image), and take_screenshot photographs
it. They are ordinary function tools — your process makes the request, your process pays for
it — so what this chapter really teaches is the two seams you plug your own code into, and the
convention that carries pixels back to the model.
Step 1
They are already on the app
app.tools carries the three factories, so the common case needs no import beyond
the one you already have. Each takes a config object and returns a tool you put in an agent's
tools array — the same FunctionTool
app.tool returns. Building one touches no network and starts
no browser: everything happens when the model calls it.
Every cell on this page runs with no key. The model's turns are scripted by the test kit, and the web tools themselves really execute — against stand-ins you can read, so nothing here depends on a live site being up.
import { adk } from '@animahealth/adk'
const app = adk()
const pageFetcher = app.tools.fetchPage()
const screenshotter = app.tools.takeScreenshot({ maxTargets: 4 })
// A description is prompt, and config writes part of it: maxTargets above lands in the sentence
// the model reads before deciding.
const contracts = [pageFetcher, screenshotter].map((tool) => ({
name: tool.name,
description: tool.description,
}))
contracts
The @animahealth/adk/web subpath exports the same three factories in their
ToolSpec form, which builds the same tool; reach for
the subpath when you also need what only it exports: the SerperProvider class,
the pipeline and result types, and the standalone helpers (fetchPagesBatch,
screenshotPage, screenshotPages, closeBrowser).
One thing these are not: a provider's own hosted search. That runs inside the model provider
and has no execute of yours; the ADK models it separately as a provider tool.
Everything on this page is a function tool your process runs.
Step 2
webSearch, and the seam under it
The model sees a tool called web_search that takes a query and, when
you allow it, a country code. The result is one field: results, an
array of rows in the provider's ranking order.
type SearchRow = {
title: string
url: string
snippet: string
position: number
date?: string
content?: string // only when autoFetch pulled the page in
}
Where those rows come from is a SearchProvider: a name and a
search(query, options). Pass one as provider and the tool is yours —
an internal index, a cached corpus, a different search API. Omit it and the tool builds a
SerperProvider, which reads SERPER_API_KEY
while you are building the tool. No key means no tool: it throws at wiring time, not
on the first model call.
The cell below runs the real web_search tool with a stand-in provider, so it
needs no key and makes no request. Only the provider is fake — the tool, its schema, the
agent, and the ledger are the shipped ones. Watch what the tool hands the provider: the config
you set becomes the provider's options.
import { openai } from '@animahealth/adk/openai'
import { findEventsByType, model, runTest, user } from '@animahealth/adk/testing'
// A stand-in for the search API. Real shape, invented rows — it echoes the options it was handed
// so you can see what the tool's config actually does.
const localIndex = {
name: 'stand-in',
search: async (query: string, options?: { numResults?: number; searchType?: string }) => [
{
title: `stand-in row for "${query}"`,
url: 'https://example.com/adk',
snippet: `asked for ${options?.numResults} ${options?.searchType} results`,
position: 1,
},
],
}
const searchTool = app.tools.webSearch({
numResults: 3,
searchType: 'news',
provider: localIndex,
// The country field leaves the schema the model is shown, and a country sent anyway is
// stripped before execute sees the arguments.
allowCountryOverride: false,
})
const researcher = app.agent({
name: 'researcher',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Search before you answer.'), app.context.history()],
tools: [searchTool],
})
const searchRun = await runTest(researcher, [
user('What has the ADK shipped lately?'),
model({ toolCalls: [{ name: 'web_search', args: { query: 'anima adk release' } }] }),
model('Here is what the search turned up.'),
])
const searched = findEventsByType(searchRun.events, 'tool_result')[0]?.result
searched
The rest of the config is a short list.
-
numResults(default 10),searchType('web','news', or'images', default'web'), andcountryare passed to the provider, which decides what to do with them. -
allowCountryOverride(default true) decides whethercountryis in the schema at all. A configcountryis the fallback when the model does not send one. -
autoFetchfollows the topautoFetchTopresults (default 3) with the fetch machinery of the next section and copies each page's markdown onto that row'scontent.autoFetchRender,proxy, andpipelinessteer those fetches. It is one tool call and several page loads — budget for it.
Wiring the shipped provider is two lines, and the key can come from your own config rather than the environment.
import { SerperProvider, webSearch } from '@animahealth/adk/web'
// Reads SERPER_API_KEY when the argument is omitted; either way it throws here, not later.
const search = app.use(webSearch({ provider: new SerperProvider(process.env.SEARCH_KEY) }))
Step 3
fetchPage, and the seam under it
One tool, three kinds of thing at the other end of a URL. An HTML page is reduced to article
markdown — Readability picks the content, Turndown renders it. A PDF comes back as a document
the model can look at, truncated to maxPdfPages (default 10). An image comes back
as an image, scaled to fit 1280px. The kind is decided by the response's
content-type, falling back to the URL's extension.
The model passes urls — one string or an array, capped at
maxUrls (default 20) — and optionally includeSelectors, which
annotates the markdown with [@selector] markers naming elements that can be
screenshotted later. You get one entry per URL, in order.
type FetchedPage = {
success: boolean
url: string
title?: string
content?: string // markdown, for HTML pages
wordCount?: number
httpStatus?: number
error?: 'timeout' | 'blocked' | 'not_found' | 'network_error' | 'api_error'
pipeline?: string // set by the pipeline that answered, if one did
raw?: unknown // a pipeline's structured payload, if it supplied one
mediaIndex?: number // this entry's PDF or image, by position — see the next section
}
A failure is never an exception here: the entry comes back with
success: false and an error from that union, and the model reads it
alongside the entries that worked. The rest of the config is timeout (default 30s
per URL), concurrency (default 5), render, proxy,
includeSelectors, and pipelines.
render: true is the escape hatch for pages that are empty without JavaScript: the
fetch goes through a real browser instead of fetch. It runs on the shared browser
of step 5, and it is the only fetch path a proxy applies to.
A FetchPipeline is the seam: a name, the patterns it
claims, and a fetch. Pipelines are consulted before the network, in
order, and the first one whose pattern matches and whose result has
success: true wins. A pipeline that returns success: false is
treated as having declined, and the ordinary fetch runs anyway — so a pipeline that exists to
refuse a URL must succeed at refusing it.
import { fetchPage } from '@animahealth/adk/web'
const noInternalHosts = {
name: 'internal-hosts',
patterns: [/\.internal\.example\.com/],
// success: true — this is the answer, not a failed attempt. Returning false would fall through
// to the real fetch and defeat the point.
async fetch(url: string) {
return {
success: true,
url,
title: 'Blocked',
content: 'Internal hosts are not readable from here. Ask the platform team instead.',
}
},
}
const guardedFetch = app.use(fetchPage({ pipelines: [noInternalHosts] }))
Step 4
How pixels reach the model
A tool result is JSON, and a PDF is not. The ADK's convention for that is one reserved key: a
tool may return __media alongside its result, and the runtime lifts it off before
the result is recorded. The result the model reads never contains it; the
tool_result event carries it in a sibling field, media, and the
provider serializer attaches those parts to the tool's answer — an image part for an image, a
file part for a document.
fetch_page and take_screenshot both use it, which is why an entry
names a mediaIndex rather than carrying bytes. The cell below runs the real
fetch_page tool with a pipeline that answers locally, so the only invented thing
is the payload the pipeline hands back.
// A pipeline that claims one URL pattern and answers it from memory: no network, no jsdom, no
// browser. Where `media` sits here, the real tool puts the bytes it downloaded.
const localPipeline = {
name: 'stand-in',
patterns: [/example\.com\/report/],
fetch: async (url: string) => ({
success: true,
url,
title: 'Q3 report',
content: '# Q3 report\n\nRevenue held flat.',
wordCount: 6,
pipeline: 'stand-in',
media: { type: 'document' as const, mimeType: 'application/pdf', data: 'JVBERi0xLjQK' },
}),
}
const reportFetcher = app.tools.fetchPage({ pipelines: [localPipeline] })
const reader = app.agent({
name: 'reader',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Read the page before summarising it.'), app.context.history()],
tools: [reportFetcher],
})
const fetchRun = await runTest(reader, [
user('Summarise https://example.com/report'),
model({ toolCalls: [{ name: 'fetch_page', args: { urls: 'https://example.com/report' } }] }),
model('Revenue held flat in Q3.'),
])
const fetched = findEventsByType(fetchRun.events, 'tool_result')[0]
const carried = {
// What the model reads as the call's result: no bytes, just a pointer.
result: fetched?.result,
// What travels beside it. Printed as a summary — the data is base64.
media: fetched?.media?.map((part) => ({
kind: part.type,
describes: part.source.type === 'base64' ? part.source.mimeType : part.source.url,
})),
}
carried
The rule this demonstrates. __media is stripped from the
result and lands on event.media. A result that quietly grew a
__media key — from a finalize, from your own tool — is treated the
same way, so do not use that key for anything else.
Step 5
takeScreenshot and its one browser
Each target is a url and an optional CSS selector; the model may
pass one target or an array of up to maxTargets (default 10). A selector captures
that element — and if it matches nothing visible, the capture falls back to the viewport
rather than failing. Without a selector, fullPage (default true) decides between
the whole scrollable page and the viewport. Images are capped at 1280px on both axes, whatever
maxWidth and maxHeight ask for.
Results follow the same split as fetch_page: a small entry per target, and the
pictures on media.
type Shot = {
success: boolean
url: string // where the page ended up, after redirects
selector?: string
title?: string
width?: number
height?: number
error?: string
}
maxTargets is not a suggestion checked in your code — it is in the schema, so an
over-long request is rejected at the border and the model is told why. That is worth seeing
rather than believing: the tool built in step 1 allows four.
const target = (n: number) => ({ url: `https://example.com/${n}` })
const border = {
one: screenshotter.schema.safeParse({ targets: target(1) }).success,
four: screenshotter.schema.safeParse({ targets: [1, 2, 3, 4].map(target) }).success,
five: screenshotter.schema.safeParse({ targets: [1, 2, 3, 4, 5].map(target) }).success,
withSelector: screenshotter.schema.safeParse({
targets: { url: 'https://example.com', selector: 'main.report' },
}).success,
}
border
Two operational facts about the browser underneath. It is a process-wide singleton: the first
launch wins, so a proxy or a headless flag on a later call is
ignored until the browser is closed. And it keeps the process alive — a script that
screenshots and exits should call closeBrowser(). At most five browser operations
run at once, across every tool sharing that browser; a call with more targets than that starts
them all and they queue.
import { closeBrowser, screenshotPage } from '@animahealth/adk/web'
// The same capture the tool performs, callable directly — useful in a script or a test.
const shot = await screenshotPage('https://example.com', { selector: 'main', timeout: 15000 })
await closeBrowser() // otherwise the process does not exit
URLs that end in a download extension (.pdf, .zip, and friends) are
refused before a page is opened, with an error saying so. Send those to
fetch_page, which turns a PDF into something the model can read.
Step 6
Keys, packages, proxies
These tools are the ADK's only paid, installed, network-facing surface. Everything they need is an optional peer dependency, imported the moment it is needed and not before — so a dependency you skipped costs you nothing until a model calls the path that wants it, and then it costs you that call.
| Path | Needs | Without it |
|---|---|---|
webSearch(), default provider |
SERPER_API_KEY |
Throws while building the tool, with the variable name and where to get a key. |
fetch_page on an HTML page |
@mozilla/readability, jsdom, turndown |
The entry comes back success: false with
error: 'network_error' — a missing package is reported as a network
fault, so check the console before blaming the site.
|
fetch_page on a PDF |
pdf-lib |
A console warning, and the whole PDF goes to the model —
maxPdfPages stops applying.
|
fetch_page on an image |
sharp |
The original bytes are sent unresized, with no width or height. |
fetch_page({ render: true }) |
playwright and its browsers |
Installed package, missing browsers: the call throws, naming
npx playwright install.
|
take_screenshot |
playwright and its browsers, sharp |
Throws with the install command for whichever is missing. |
| Proxied browsing |
PROXY_HOST, PROXY_PORT, optionally
PROXY_USERNAME and PROXY_PASSWORD
|
Direct connections. The environment proxy and the proxy config both apply
to browser launches only — plain fetch_page ignores them.
|
A throw out of a web tool is not a crash. It is the tool failure contract from
Tools: the exception becomes the tool_result event's
error, the model reads it, and the run continues. That is why these errors are
written as instructions — the audience for "run npx playwright install" is a language model
deciding what to do next, and it will tell your user.
Next
Tools you didn't write
Search, fetch, and screenshot are three tools someone else wrote and you configured. The general case of that is MCP: a server hands your agent tools it discovers at connect time, with the same schemas and the same ledger. That is Tools you didn't write.