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.

Builds on · Tools Cells · no key needed Surface · app.tools, @animahealth/adk/web

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 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.