Mastra · TypeScript
Build a research agent with Mastra
Build a research agent on Mastra, combining typed tools, durable workflows and built-in memory in one TypeScript codebase.
What you are building
Your service
Agent
Instructions, model, tools
Memory
Per-thread, optional
Workflow
Durable, suspendable
Tools
webSearch
Typed in and out
assembleContext
Typed in and out
Services
Any AI SDK provider
claude-opus-5 here
unlob
Your own index
Install
npm i @mastra/core @ai-sdk/anthropic zodStep by step
Create the tools
createTool takes Zod schemas for both input and output. Typing the output is worth the extra minute — it is what makes a workflow that consumes the tool type-safe end to end.
tools.tstypescript import { createTool } from "@mastra/core/tools"; import { z } from "zod"; export const webSearch = createTool({ id: "web-search", description: "Search the web. Returns metadata only — never page bodies. Use " + "minSources: 3 for factual claims that need corroboration.", inputSchema: z.object({ query: z.string(), minSources: z.number().default(2), }), outputSchema: z.object({ results: z.array( z.object({ url: z.string(), title: z.string(), snippet: z.string(), independent_sources: z.number(), }), ), }), execute: async ({ context }) => { const params = new URLSearchParams({ q: context.query, min_independent_sources: String(context.minSources), limit: "8", }); const res = await fetch(`https://api.unlob.com/search?${params}`, { headers: { "x-api-key": process.env.UNLOB_API_KEY! }, }); return { results: (await res.json()).results }; }, });Define the agent
Mastra agents take a model from any AI SDK provider, so you are not locked to one vendor. Memory is a constructor option rather than something you bolt on.
agent.tstypescript import { Agent } from "@mastra/core/agent"; import { anthropic } from "@ai-sdk/anthropic"; import { webSearch, assembleContext } from "./tools"; export const researcher = new Agent({ name: "researcher", instructions: "Search before you answer. Prefer claims carried by several independent " + "sources, and say so when a claim rests on one. Always cite URLs.", model: anthropic("claude-opus-5"), tools: { webSearch, assembleContext }, });Give it memory, then make it durable
Memory is a constructor option rather than something you bolt on, and a workflow wraps the agent so a long research run can suspend and resume. This is the pair of features that separates Mastra from a plain model client.
durable.tstypescript import { Memory } from "@mastra/memory"; import { createWorkflow, createStep } from "@mastra/core/workflows"; import { z } from "zod"; // Per-thread memory. Without this the agent forgets between calls, which // is usually mistaken for a bug. export const researcher = new Agent({ name: "researcher", instructions: "Search before you answer. Always cite URLs.", model: anthropic("claude-opus-5"), tools: { webSearch, assembleContext }, memory: new Memory(), }); const research = createStep({ id: "research", inputSchema: z.object({ question: z.string() }), outputSchema: z.object({ answer: z.string() }), execute: async ({ inputData }) => { const result = await researcher.generate(inputData.question, { memory: { thread: "research-1", resource: "user-1" }, }); return { answer: result.text }; }, }); export const researchWorkflow = createWorkflow({ id: "research-workflow", inputSchema: z.object({ question: z.string() }), outputSchema: z.object({ answer: z.string() }), }) .then(research) .commit();
What happens when it runs
The whole thing
Complete and runnable. Set the two environment variables and it works.
/**
* A research agent on Mastra, backed by the unlob web index.
*
* npm i @mastra/core @ai-sdk/anthropic zod
* export UNLOB_API_KEY=ulb_...
* export ANTHROPIC_API_KEY=sk-ant-...
*/
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const headers = { "x-api-key": process.env.UNLOB_API_KEY! };
const webSearch = createTool({
id: "web-search",
description:
"Search the web. Returns metadata only — never page bodies. Use " +
"minSources: 3 for factual claims that need corroboration.",
inputSchema: z.object({
query: z.string(),
minSources: z.number().default(2),
}),
outputSchema: z.object({
results: z.array(
z.object({
url: z.string(),
title: z.string(),
snippet: z.string(),
independent_sources: z.number(),
}),
),
}),
execute: async ({ context }) => {
const params = new URLSearchParams({
q: context.query,
min_independent_sources: String(context.minSources),
limit: "8",
});
const res = await fetch(`https://api.unlob.com/search?${params}`, { headers });
return { results: (await res.json()).results };
},
});
const assembleContext = createTool({
id: "assemble-context",
description:
"Build a corroborated, deduplicated context pack packed to a token " +
"budget. Prefer this over several searches when you need source material.",
inputSchema: z.object({
query: z.string(),
budget: z.number().default(3000),
}),
outputSchema: z.object({
used_tokens: z.number(),
passages: z.array(
z.object({ url: z.string(), text: z.string(), reason: z.string() }),
),
}),
execute: async ({ context }) => {
const params = new URLSearchParams({
query: context.query,
budget: String(context.budget),
min_independent_sources: "2",
});
const res = await fetch(
`https://api.unlob.com/assemble_context?${params}`,
{ headers },
);
return res.json();
},
});
export const researcher = new Agent({
name: "researcher",
instructions:
"Search before you answer. Prefer claims carried by several independent " +
"sources, and say so when a claim rests on one. Always cite URLs.",
model: anthropic("claude-opus-5"),
tools: { webSearch, assembleContext },
});
const result = await researcher.generate(
"What changed in the EU AI Act GPAI rules in 2026?",
);
console.log(result.text);What will go wrong
The failures that cost an afternoon rather than a minute, because they produce something that looks like it is working.
Tool arguments arrive on `context`, not as the first positional parameter. Destructuring `({ query })` gives you undefined and a confusing runtime error rather than a type error.
outputSchema is optional but worth writing. Without it a workflow consuming the tool gets `any`, which quietly removes the reason you chose a typed framework.
Memory is per-thread and off unless you configure it. An agent that seems to forget between calls is usually an agent with no memory configured rather than a bug.
Mastra moves quickly. Pin your version — import paths under @mastra/core have changed between minor releases.
Where this agent can go next
Every result the agent receives is a node in the coverage graph. These are the tools you can add to it without leaving Mastra — each is one more entry in the same tools list.
Frequently asked questions
How is this different from the Vercel AI SDK?
Mastra uses AI SDK providers underneath, so the model layer is shared. What Mastra adds is orchestration: durable workflows that suspend and resume, built-in memory, agent networks and evals. The AI SDK is a library for talking to models; Mastra is a framework for running agents.
Can I run it serverless?
Yes, and it deploys to Vercel, Cloudflare and Node servers. Durable workflows need a storage adapter to survive across invocations — in-memory works locally and will lose state on a cold start.
You need a key to run this
10,000 requests a month on the free tier, no card. Enough to build the agent and put a real evaluation set through it.