Giving a Mastra Agent Live Web Search
Mastra's tool API is Zod-first and TypeScript-native. Here's a search tool that actually type-checks against a real SERP response.


- The search tool
- Typing the response loosely on purpose
- News, with its different shape
- Reading a page
- Wiring the agent
Mastra puts tools where TypeScript people expect them: a createTool call with Zod schemas on both ends, so the agent’s inputs are validated and your execute function has real types. The friction is on the other side — modelling a SERP response that has a dozen optional arrays.
The search tool
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
const API_KEY = process.env.SERPLY_API_KEY!;
const BASE = 'https://api.serply.io/v1';
const PROXY_LOCATIONS = [
'US', 'GB', 'CA', 'IE', 'FR', 'DE', 'SE', 'IN', 'JP', 'KR', 'SG', 'AU', 'BR', 'EU',
] as const;
export const webSearch = createTool({
id: 'web-search',
description:
'Search the live web via Google. Returns ranked results with title, URL, and ' +
'snippet. Use for anything current, niche, or factual that you should not ' +
'answer from memory. Snippets are 1-2 sentences — call read-page for full text.',
inputSchema: z.object({
query: z
.string()
.min(2)
.describe('Keyword-style search query, not a full sentence.'),
num: z.number().int().min(1).max(100).default(10)
.describe('Number of results to return.'),
location: z.enum(PROXY_LOCATIONS).optional()
.describe('Country to search from. Set when the query is location-sensitive.'),
}),
outputSchema: z.object({
results: z.array(
z.object({
title: z.string(),
url: z.string(),
snippet: z.string(),
position: z.number().nullable(),
}),
),
relatedQuestions: z.array(z.string()),
}),
execute: async ({ context }) => {
const { query, num, location } = context;
const headers: Record<string, string> = { 'X-Api-Key': API_KEY };
if (location) headers['X-Proxy-Location'] = location;
const url = `${BASE}/search/q=${encodeURIComponent(query)}&num=${num}`;
const res = await fetch(url, { headers });
if (res.status === 429) {
throw new Error(
'Rate limited by the search API. Wait a few seconds before searching again, ' +
'and prefer refining one query over issuing several.',
);
}
if (!res.ok) throw new Error(`Search failed: ${res.status}`);
const body = (await res.json()) as SerpResponse;
return {
results: (body.results ?? []).map((r) => ({
title: r.title ?? '',
url: r.link ?? '',
snippet: r.description ?? '',
position: r.position ?? null,
})),
relatedQuestions: (body.related_questions ?? []).map((q) =>
typeof q === 'string' ? q : (q.question ?? ''),
).filter(Boolean),
};
},
});
Note the URL shape: /search/q=... with the query in the path. A ?q= querystring is the single most common integration mistake against this API.
Typing the response loosely on purpose
interface SerpResult {
title?: string;
link?: string;
description?: string;
position?: number;
realPosition?: number;
result_type?: string;
metadata?: { display_url?: string };
}
interface SerpResponse {
results?: SerpResult[];
related_questions?: Array<string | { question?: string }>;
related_searches?: unknown[];
knowledge_graph?: unknown;
answers?: unknown[];
ads?: unknown[];
image_results?: unknown[];
shopping_ads?: unknown[];
places?: unknown[];
local_businesses?: unknown[];
carousel?: unknown[];
query?: string;
device_type?: string;
device_region?: string;
}
Everything optional, unknown for the feature arrays you aren’t consuming. A SERP is a union of a dozen page layouts; a strict interface will break the first time someone searches for a stock ticker and gets a knowledge panel shaped differently than you modelled it. The outputSchema on the tool is where strictness belongs, because that’s the contract with the agent.
News, with its different shape
export const newsSearch = createTool({
id: 'news-search',
description:
'Search recent news articles. Returns headline, link, summary, source and ' +
'publication date. Use when recency matters; use web-search for general facts.',
inputSchema: z.object({
query: z.string().min(2).describe('Topic or entity to find coverage of.'),
}),
outputSchema: z.object({
articles: z.array(
z.object({
title: z.string(),
url: z.string(),
summary: z.string(),
published: z.string().nullable(),
source: z.string().nullable(),
}),
),
}),
execute: async ({ context }) => {
const res = await fetch(
`${BASE}/news/q=${encodeURIComponent(context.query)}`,
{ headers: { 'X-Api-Key': API_KEY } },
);
if (!res.ok) throw new Error(`News search failed: ${res.status}`);
const body = await res.json();
const entries = body?.feed?.entries ?? [];
return {
articles: entries.map((e: any) => ({
title: e.title ?? '',
url: e.link ?? '',
summary: e.summary ?? '',
published: e.published ?? null,
source: e.source ?? null,
})),
};
},
});
News articles live under feed.entries, not results. Normalising both tools to a flat array in the output schema means the agent sees one consistent shape and you keep the difference in one place.
Reading a page
export const readPage = createTool({
id: 'read-page',
description:
'Fetch the full text of a URL as markdown. Use after web-search when a ' +
'snippet is not enough to answer. Costs more than a search — read at most ' +
'the 2-3 most promising results.',
inputSchema: z.object({
url: z.string().url().describe('Absolute URL from a previous search result.'),
}),
outputSchema: z.object({
text: z.string(),
truncated: z.boolean(),
}),
execute: async ({ context }) => {
const res = await fetch(`${BASE}/request`, {
method: 'POST',
headers: { 'X-Api-Key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ url: context.url, response_type: 'markdown' }),
});
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const full = await res.text();
const LIMIT = 15_000;
return { text: full.slice(0, LIMIT), truncated: full.length > LIMIT };
},
});
res.text(), not res.json(). Markdown mode returns the content as the raw response body. The full response type wraps HTML in {"data": "..."} if you’d rather parse it yourself.
Returning truncated as a real field rather than silently cutting matters: the agent can decide to search for a different source instead of reasoning over half a document as though it were whole.
Wiring the agent
import { Agent } from '@mastra/core/agent';
import { anthropic } from '@ai-sdk/anthropic';
export const researcher = new Agent({
name: 'researcher',
instructions: `You answer questions using live web sources.
Always search before answering anything factual, current, or specific — do not
rely on memory for those. Search first, then read the 2-3 most promising results
in full before drawing conclusions.
Cite every factual claim with the URL you got it from. If sources disagree, say
so and give both. If the search results do not answer the question, say that
plainly rather than filling the gap.`,
model: anthropic('claude-sonnet-4-5'),
tools: { webSearch, newsSearch, readPage },
});
The instruction that earns its keep is the last one. Without an explicit permission to fail, a model with search tools will keep searching, then answer from memory anyway and cite whatever it found. With it, “the sources don’t cover this” becomes an acceptable outcome, and the agent stops manufacturing coverage.