---
title: How to run a multi-step research agent on Vercel
description: An end-to-end architecture for production research agents on Vercel using Sandbox, Workflows, and AI Gateway with isolated execution per step.
url: /kb/guide/how-to-run-a-multi-step-research-agent-on-vercel
canonical_url: "https://vercel.com/kb/guide/how-to-run-a-multi-step-research-agent-on-vercel"
last_updated: 2026-07-28
authors: Vercel
related:
  - /docs/vercel-blob
  - /docs/eve
  - /docs/workflows
  - /docs/ai-gateway
  - /docs/ai-gateway/models-and-providers
  - /docs/ai-gateway/observability-and-spend/api-key-budgets
  - /docs/sandbox
  - /docs/sandbox/concepts/firewall
  - /docs/workflows/concepts
  - /kb/guide/how-to-build-a-durable-ai-code-agent-on-vercel
  - /kb/guide/ai-gateway-and-ai-sdk
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Research agents are one of the most useful and most common agent patterns there is. The pattern is a loop: search, read, aggregate, synthesize, then decide whether to keep going. Coding agents ship with research agents built in. Claude Code's Explore and Plan subagents are an example of this research loop pointed at a codebase.

Running the loop itself is now the easy part. The AI SDK gives you an agent class that runs it out of the box, and all you have to do is hand that class a model and a set of tools. The hard part is everything around the loop. A production research run has to stay alive for minutes or hours, use the right model for different kinds of tasks, run model-written code somewhere safe, and put the finished research document somewhere a person or another system can fetch it.

This guide shows you how to build a durable, secure research agent you can deploy directly to Vercel.

## Overview

In this guide, you'll learn how to:

- Build a research agent that searches the web and aggregates what it finds into structured data, using the [AI SDK](https://ai-sdk.dev/docs/agents/overview)'s agent primitives
  
- Make runs durable, so an agent doing research over multiple minutes or hours survives a crash mid-run
  
- Route each task to the model that makes the most sense for the job
  
- Implement a sandbox for tool calls that run code the model writes, since that code can't be reviewed before it runs
  
- Store the finished research document somewhere that outlives the run
  
- Start a run with an authenticated API call, then check its status and retrieve the result
  

## Prerequisites

Before you start, you need:

- A [Vercel account](https://vercel.com/signup)
  
- [Node.js](https://nodejs.org) 22 or later
  
- A [Next.js](https://nextjs.org) project, new or existing, that you can deploy to Vercel
  
- A web search API. This guide uses [Exa from the Vercel Marketplace](https://vercel.com/marketplace/exa), which adds an `EXA_API_KEY` to your project when you install it
  
- A [Blob store](https://vercel.com/docs/vercel-blob), created from your project's Storage tab, where the finished research documents will live
  

Install the packages the guide builds on:

`npm install workflow ai zod @ai-sdk/workflow @vercel/sandbox @vercel/blob exa-js`

In this stack, `workflow` runs durable functions, `ai` provides the agent loop, `zod` validates tool inputs, `@ai-sdk/workflow` makes the loop durable, `@vercel/sandbox` runs model-written code, `@vercel/blob` stores the output, and `exa-js` is the search client. The examples use npm. The `ai` and `@ai-sdk/workflow` packages move together, so install and upgrade them at the same time.

If you'd rather start from working code, deploy the [Lead Agent template](https://vercel.com/templates/next.js/lead-processing-agent). It qualifies inbound leads with the same loop this guide builds, running deep research over web search inside a durable workflow. The [source is on GitHub](https://github.com/vercel-labs/lead-agent).

## How it works

The agent itself is the loop that takes a research task, picks a tool, reads the result, and decides what to do next. A framework like [eve](https://vercel.com/docs/eve) gives you that loop with its defaults prebuilt. This guide builds it directly on the AI SDK's agent primitives, so you can see each piece as you add it.

The loop runs on four key primitives:

- Durable orchestration keeps a run alive and resumes it after a crash
  
- Model routing gives each kind of work the model best suited to it
  
- A sandbox runs model-written code apart from your application
  
- Output storage gives the research document a durable home
  

The sandbox isn't a strict requirement for the research agent to run, but if the model decides to write code that processes data or parses files, that code needs to be executed in an isolated environment. When the agent needs to run code, it can spin up a secure microVM in milliseconds, then shut it down when the task is complete.

## Step 1: Build the research agent

The agent this guide builds does two things:

1. It searches the web.
   
2. It aggregates what it finds into structured records.
   

Each of those actions is a tool. The loop that connects them is [`ToolLoopAgent`](https://ai-sdk.dev/docs/agents/overview), the AI SDK class for building your own model and tools loop, and it is the smallest part of this step.

In your code, a tool is a function the model can call, and all of it is plain TypeScript. `tool()` takes a description, an `inputSchema`, and an `execute` function. The description and schema teach the model what the tool is for and what arguments it takes. The `execute` function is backend code like any other in your repository, and the model only fills in the arguments.

The search tool uses the Exa client and the `EXA_API_KEY` that the Marketplace integration added to your project:

`import { tool, generateObject } from 'ai'; import { z } from 'zod'; import Exa from 'exa-js'; const exa = new Exa(process.env.EXA_API_KEY); export const webSearch = tool({ description: 'Search the web and return the top results with their contents', inputSchema: z.object({ query: z.string().describe('The search query to run'), }), // The model fills in the query, and the rest is regular backend code execute: async ({ query }) => { const { results } = await exa.searchAndContents(query, { numResults: 5, text: true, }); return results.map((r) => ({ title: r.title, url: r.url, text: r.text })); }, });`

The aggregation tool turns raw findings into typed records. It uses `generateObject`, which validates the model's output against a Zod schema before your code sees it, so you never parse raw model text by hand. Aggressive aggregation also keeps the loop healthy over a long run, because compressed records consume far less context than raw pages:

``export const aggregateFindings = tool({ description: 'Aggregate raw research findings into structured records', inputSchema: z.object({ findings: z.string().describe('The raw findings to aggregate'), }), execute: async ({ findings }) => { // Returns records that already match the schema, never text you parse by hand const { object } = await generateObject({ model: 'anthropic/claude-sonnet-5', schema: z.object({ records: z.array( z.object({ claim: z.string(), source: z.string(), confidence: z.enum(['high', 'medium', 'low']), }), ), }), prompt: `Extract the key claims and their sources from these findings:\n\n${findings}`, }); return object; }, });`` With the tools written, here is the entire research agent: `import { ToolLoopAgent, stepCountIs } from 'ai'; // The whole research loop: a model, instructions, and a tools map export const researchAgent = new ToolLoopAgent({ model: 'anthropic/claude-fable-5', instructions: 'You are a research agent. Search the web, aggregate what you find into structured records, and synthesize a report with sources.', tools: { webSearch, aggregateFindings }, stopWhen: stepCountIs(25), // the ceiling on how long the loop can run });` The model reads each tool result and decides what to call next, so when a search comes back thin it reformulates and searches again. There is no separate planning phase in this build. If your workload benefits from an explicit research plan, add a step that produces one and pass it in with the prompt. The one setting to treat as required is `stopWhen`. A loop with a paid search tool will keep searching until something tells it to stop, which is why the construction above caps it at 25 steps. Model calls authenticate through your Vercel project's AI Gateway credentials, so link the project and run `vercel env pull` first to get a development OIDC token. Step 3 explains how that authentication works. Run it locally with `generate`: `// Runs the loop until the model stops calling tools and writes its answer const result = await researchAgent.generate({ prompt: 'Research the current state of server-side WebAssembly runtimes and aggregate what you find.', }); console.log(result.text);` `result.text` holds the final answer, and `result.steps` records every tool call the model made along the way. Ask it a research question and it comes back with structured records and sources. At this point you can run the agent, but the whole job lives inside one process. Step 2 fixes that by making runs durable. ## Step 2: Make the agent durable The agent from Step 1 dies with its process. A research run that works for forty minutes and crashes at minute thirty-nine loses everything. The [Workflow SDK](https://vercel.com/docs/workflows) fixes this by checkpointing the run. A step is a function marked with `'use step'`, and every step records its output as it completes. If the process dies mid-run, a fresh process picks the run up, replays completed steps from their recorded output (instead of executing them again), and continues from where it stopped. The SDK is open source and self-hostable. When you deploy on Vercel, the dashboard gives you detailed observability for each workflow and step.

Wire the workflow runtime into your build in `next.config.ts`:

`import { withWorkflow } from 'workflow/next'; // One wrapper and every workflow function in the project is durable export default withWorkflow({ // your existing Next.js config });`

The durable version of the agent is `WorkflowAgent` from `@ai-sdk/workflow`. It runs the same loop with the same tools and persists the loop's state across step boundaries. Two directives wire it in. The workflow function is marked `'use workflow'` on its first line, and each tool's `execute` gets `'use step'` on its first line, which turns every tool call into its own checkpointed step with automatic retries:

`execute: async ({ query }) => { 'use step'; // each tool call becomes a checkpointed step with automatic retries const { results } = await exa.searchAndContents(query, { numResults: 5, text: true, }); return results.map((r) => ({ title: r.title, url: r.url, text: r.text })); },`

Then wrap the agent in a workflow function:

`import { WorkflowAgent, type ModelCallStreamPart } from '@ai-sdk/workflow'; import { getWritable } from 'workflow'; import { stepCountIs } from 'ai'; import { webSearch, aggregateFindings } from '@/lib/tools'; // The same loop from Step 1, now checkpointed at every step boundary export async function researchWorkflow(prompt: string) { 'use workflow'; const agent = new WorkflowAgent({ model: 'anthropic/claude-fable-5', instructions: 'You are a research agent. Search the web, aggregate what you find into structured records, and synthesize a report with sources.', tools: { webSearch, aggregateFindings }, stopWhen: stepCountIs(25), }); await agent.stream({ messages: [{ role: 'user', content: prompt }], writable: getWritable<ModelCallStreamPart>(), }); }` One API change comes with the wrapper. `WorkflowAgent` exposes `stream()` and not `generate()`, because a run that lasts hours cannot return a single awaited value to whoever started it. Output goes to the writable you pass in, a persisted stream you can read while the run is still alive. Retries and replay are handled for you. Completed steps never run twice, and a step that fails will retry automatically. One best practice to keep in mind is placing side effects inside steps, so a retry re-runs a whole step, rather than repeating half of it. Every step, its input, its output, and any error is recorded automatically, and you can inspect runs in your dashboard under Observability > Workflows. If a tool ever needs human sign-off, set `needsApproval` on it and the run suspends indefinitely until someone responds, without burning compute. ## Step 3: Route each task to the right model So far, every model call the agent makes goes to `anthropic/claude-fable-5`, even though they represent different kinds of work. The loop plans, judges results, and synthesizes, which is what you want to pay a frontier model to get right. The `aggregateFindings` tool extracts claims from text it is handed, which a smaller model does well for a fraction of the price. Using the latest frontier model for every task often means overpaying for work a cheaper model can perform just as well. The fix is a string swap, because the model has been a string all along. When you pass a plain string as the model, the AI SDK routes the call through [AI Gateway](https://vercel.com/docs/ai-gateway), its default provider. Model strings follow the format `creator/model-name`, and any model in the [catalog](https://vercel.com/docs/ai-gateway/models-and-providers) is one edit away.

To optimize for cost and performance, keep the loop on the frontier tier and move aggregation to a smaller model:

``const { object } = await generateObject({ // Aggregation moves to a smaller model while the loop keeps claude-fable-5 model: 'anthropic/claude-sonnet-5', schema: recordsSchema, prompt: `Extract the key claims and their sources from these findings:\n\n${findings}`, });``

There are no provider keys in this code. On Vercel, every deployment gets a `VERCEL_OIDC_TOKEN` and Gateway accepts it automatically. `vercel env pull` gives your dev environment the same short-lived token, which is how Step 1's run worked locally. The only key this agent manages itself is the Exa key.

Cost optimization across models isn't the only benefit of routing. An agent that runs for hours will eventually see its primary model fail. AI Gateway automatically handles failover to backup models you name, passed as provider options in any individual model call:

`const { object } = await generateObject({ model: 'anthropic/claude-sonnet-5', // Fallback models Gateway tries when the primary fails providerOptions: { gateway: { models: ['google/gemini-2.5-flash'] } }, // ...schema and prompt as before });` The array lists fallbacks only, so keep your primary in `model` rather than repeating it in the array. AI Gateway also gives you the ability to set spend limits. Create an [AI Gateway API key with a budget](https://vercel.com/docs/ai-gateway/observability-and-spend/api-key-budgets), and the gateway will check the balance before each request. When the limit is reached, requests stop. Budgets attach to keys, so to set a spend cap for this agent, set the budgeted key as `AI_GATEWAY_API_KEY` in your project's environment variables and the agent's calls authenticate with it instead of OIDC. Either way, every call is logged with token counts, latency, and cost in the Vercel dashboard.

## Step 4: Sandbox tool execution

Answers to research questions often need computation over gathered data, counts or comparisons across sources, and confidence breakdowns. Models accomplish that work by writing small analysis scripts, but you don't want that untrusted code to run in the same environment as the other tool calls in your application.

This step gives the research agent a [Vercel Sandbox](https://vercel.com/docs/sandbox) where it can perform analysis and run code in an isolated microVM. Sandboxes extend the agent to accomplish more complex work while keeping your production application safe:

``import { Sandbox } from '@vercel/sandbox'; import { tool } from 'ai'; import { z } from 'zod'; export function analyzeRecords(researchId: string) { return tool({ description: 'Run a Node.js analysis script over the aggregated records. The script reads records.json from its working directory and prints results to stdout.', inputSchema: z.object({ script: z.string().describe('The Node.js analysis script to execute'), records: z.string().describe('The aggregated records as a JSON string'), }), execute: async ({ script, records }) => { 'use step'; // One sandbox per run, reused across every analysis call const sandbox = await Sandbox.getOrCreate({ name: `analysis-${researchId}`, runtime: 'node24', timeout: 600_000, networkPolicy: 'deny-all', // pure computation needs no network }); await sandbox.writeFiles([{ path: 'records.json', content: Buffer.from(records) }, { path: 'analyze.js', content: Buffer.from(script) }, ]); const cmd = await sandbox.runCommand({ cmd: 'node', args: ['analyze.js'] }); return await cmd.stdout(); }, }); }`` `Sandbox.getOrCreate` keyed to the run's identifier means every analysis call in a run reuses one sandbox, and a retried step reattaches instead of provisioning another. The sandbox persists for the length of the run, and authentication works like everything else: automatically through OIDC on Vercel. The `researchId` arrives as workflow input. It's minted by the route that starts every run, which you'll build in Step 6, so the identifier already exists by the time any tool call executes. `networkPolicy` controls what the script can reach. This script computes over local files, so `deny-all` gives it nothing, which is all pure computation needs. When a model-written script legitimately needs an API, allow that domain and nothing else, and let the firewall inject the credential on the way out so the key never enters the sandbox. Policies can also change at runtime through `sandbox.update()`. The [Sandbox firewall docs](https://vercel.com/docs/sandbox/concepts/firewall#credentials-brokering) cover allowlists, credential injection, and their caveats.

## Step 5: Store the research document

The run's deliverable is the research report the loop synthesizes at the end, and it needs a home that outlives the run. That home is the Vercel Blob store you created in Prerequisites. The workflow writes the report there and returns the URL, so a person can open the document and any system can fetch it by reference:

``import { put } from '@vercel/blob'; import { Output, stepCountIs } from 'ai'; import { z } from 'zod'; import { WorkflowAgent, type ModelCallStreamPart } from '@ai-sdk/workflow'; import { getWritable } from 'workflow'; import { webSearch, aggregateFindings } from '@/lib/tools'; import { analyzeRecords } from '@/lib/analyze'; async function storeReport(report: string, researchId: string) { 'use step'; const blob = await put(`research/${researchId}.md`, report, { access: 'public', allowOverwrite: true, // a retried step overwrites the same document }); return blob.url; } export async function researchWorkflow(prompt: string, researchId: string) { 'use workflow'; const agent = new WorkflowAgent({ model: 'anthropic/claude-fable-5', instructions: 'You are a research agent. Search the web, aggregate what you find into structured records, and synthesize a report with sources.', tools: { webSearch, aggregateFindings, analyzeRecords: analyzeRecords(researchId), }, stopWhen: stepCountIs(25), }); const result = await agent.stream({ messages: [{ role: 'user', content: prompt }], writable: getWritable<ModelCallStreamPart>(), output: Output.object({ schema: z.object({ report: z.string().describe('The full research report, in Markdown'), }), }), }); // The report is written once, and its URL becomes the run's result const url = await storeReport(result.output.report, researchId); return { url }; }`` The `output` option gives the loop's final answer a typed shape, so the workflow ends holding a report string instead of parsing one out of the message history. `access: 'public'` makes the URL shareable by anyone who has it. If your research is sensitive, use a private store and serve reads through your own routes. ## Step 6: Run it and verify Deploy the agent with `vercel deploy`, or push to your connected Git repository. Confirm `EXA_API_KEY` is set in your project settings, along with a secret to gate the route below. A route that starts a long model-driven run should sit behind auth like any route that spends money on your behalf. This example checks a bearer token from an environment variable, but you can use whatever authentication your application already has: ``import { start } from 'workflow/api'; import { researchWorkflow } from '@/workflows/research'; export async function POST(request: Request) { const auth = request.headers.get('authorization'); if (auth !== `Bearer ${process.env.RESEARCH_API_KEY}`) { return new Response('Unauthorized', { status: 401 }); } const { prompt } = await request.json(); const researchId = crypto.randomUUID(); // start() enqueues the run and returns immediately while the agent works const run = await start(researchWorkflow, [prompt, researchId]); return Response.json({ runId: run.runId, researchId }); }`` `start()` hands back a `runId` and the run continues with no request attached to it. The caller leaves with two references, one for the run and one for the document it will produce. Check on a run with those two references. `getRun` from `workflow/api` returns the run's status, and the report's existence in Blob is the completion signal. Create the status route at `app/api/research/status/route.ts`: ``import { getRun } from 'workflow/api'; import { head } from '@vercel/blob'; export async function GET(request: Request) { const auth = request.headers.get('authorization'); if (auth !== `Bearer ${process.env.RESEARCH_API_KEY}`) { return new Response('Unauthorized', { status: 401 }); } const { searchParams } = new URL(request.url); const runId = searchParams.get('runId'); const researchId = searchParams.get('researchId'); if (!runId || !researchId) { return new Response('Missing runId or researchId', { status: 400 }); } // getRun reaches the run by its ID const run = getRun(runId); if (!(await run.exists)) { return new Response('Run not found', { status: 404 }); } try { const report = await head(`research/${researchId}.md`); return Response.json({ status: await run.status, reportUrl: report.url }); } catch { // The document doesn't exist yet, so the run is still working return Response.json({ status: await run.status }); } }`` Run the entire research agent flow from a terminal: `curl -X POST https://your-app.vercel.app/api/research \ -H "Authorization: Bearer $RESEARCH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt": "Research the current state of server-side WebAssembly runtimes"}' # {"runId":"...","researchId":"..."} curl "https://your-app.vercel.app/api/research/status?runId=<runId>&researchId=<researchId>" \ -H "Authorization: Bearer $RESEARCH_API_KEY" # {"status":"..."} while the agent works # {"status":"...","reportUrl":"https://...blob.vercel-storage.com/research/<researchId>.md"} when the report is ready curl https://...blob.vercel-storage.com/research/<researchId>.md` Poll the status route until `reportUrl` appears, then fetch it. The last response is the research document, structured findings with claims, sources, and confidence levels. The dashboard view from Step 2 shows every step of the run as it executed. ## When to use an agent framework This guide built directly on the AI SDK's primitives. That is the right approach when you are adding research capability to an existing application or to an agent you already run, when you are already using the AI SDK, or when you want to own each piece of the stack yourself. Everything here is a library call away from code you already have. [eve](https://eve.dev) is the open-source, batteries-included alternative. It packages the same pieces you just assembled, durable workflows, sandboxed execution for agent-written code, and AI Gateway model routing, into a single framework, so a new agent starts with all of it prebuilt. If you are starting fresh or want the assembled version of what this guide built by hand, `npx eve@latest init` scaffolds an agent.

## Resources and next steps

To go deeper on the pieces this guide assembled:

- Read the [Workflow concepts docs](https://vercel.com/docs/workflows/concepts) for capabilities this guide didn't cover, including sleeps, hooks, and how replay works
  
- Explore the [AI SDK agent docs](https://ai-sdk.dev/docs/agents/overview) for loop control, tool design, and structured output patterns you can use in your agents
  
- Browse the [AI Gateway model catalog](https://vercel.com/docs/ai-gateway/models-and-providers) to find the right model for each kind of work
  
- Learn what else the sandbox can do in the [Vercel Sandbox docs](https://vercel.com/docs/sandbox), including the filesystem, snapshots, and network policies
  
- Build a coding agent on the same foundation with [How to build a durable AI code agent on Vercel](https://vercel.com/kb/guide/how-to-build-a-durable-ai-code-agent-on-vercel)
  
- Get a more comprehensive tour of the agent stack in [Build AI agents with AI Gateway and AI SDK](https://vercel.com/kb/guide/ai-gateway-and-ai-sdk)