Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build an AI Agent with Mastra

Mastra is a TypeScript-native agent framework combining Agent, Memory, and Workflow in one package. Define tools with createTool, add Memory so the agent remembers earlier turns, then wrap a destructive tool in a Workflow step that suspends and waits for a human before it runs. This guide builds a travel agent that looks up bookings freely and only cancels one after a human approves it. Budget 45-60 minutes.

What you'll learn

  • Define typed tools with createTool and Zod input/output schemas
  • Add Memory to an Agent so it recalls a booking it looked up earlier in the same thread
  • Gate a destructive tool behind a Workflow step that suspends and resumes only after a human approves it
  • Structure a Mastra project so the Agent decides what to do and the Workflow decides what's allowed to actually run
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate11 min read45-60 minNode.js 20+, TypeScript, @mastra/core (v1.x), any OSLast updated November 2026RapidDev Engineering Team
TL;DR

Mastra is a TypeScript-native agent framework combining Agent, Memory, and Workflow in one package. Define tools with createTool, add Memory so the agent remembers earlier turns, then wrap a destructive tool in a Workflow step that suspends and waits for a human before it runs. This guide builds a travel agent that looks up bookings freely and only cancels one after a human approves it. Budget 45-60 minutes.

Quick facts about this guide
FactValue
ToolMastra
DifficultyIntermediate
Time required45-60 min
CompatibilityNode.js 20+, TypeScript, @mastra/core (v1.x), any OS
Last updatedNovember 2026

What you'll build

You'll build a support agent for a travel booking site. It can look up a reservation by confirmation code through a lookupBooking tool, and when a trip needs to change, it can cancel the booking and trigger a refund through cancelBooking. The agent can call lookupBooking on its own, but cancelBooking only ever runs inside a Workflow step that suspends the whole run and waits for a human to approve the cancellation before it executes.

Mastra ships three pieces that work together: Agent (the model plus its tools and instructions), Memory (conversation history keyed by a resource and thread id, so 'that booking' resolves correctly across turns), and Workflow (an explicit, typed sequence of steps with its own suspend-and-resume mechanism). The agent step runs first and decides whether a cancellation is warranted; a second step then suspends with resumeSchema until resumeData carries a human's decision, and only then calls the cancellation tool directly.

Choose Mastra when you're building in TypeScript and want agents, memory, and durable multi-step workflows from one framework instead of assembling them from separate packages. Choose the Vercel AI SDK instead when you want the lightest possible provider-agnostic loop with no opinion on workflow structure, or the Claude Agent SDK when you specifically want Claude's own harness and built-in tools.

Prerequisites

  • Node.js 20+ and a TypeScript project
  • An Anthropic API key (ANTHROPIC_API_KEY), or another provider key Mastra supports
  • npm install @mastra/core @mastra/memory @mastra/libsql zod
  • Basic comfort with async/await and Zod schemas

Step-by-step guide

1

Install Mastra and set your provider key

Install the core package, the memory package, a storage adapter (LibSQLStore works for local development without a separate database server), and Zod for schemas. Export your model provider's key as an environment variable; Mastra's model clients read it from the environment when you reference a model string like 'anthropic/claude-sonnet-5-5'.

typescript
1npm install @mastra/core @mastra/memory @mastra/libsql zod
2export ANTHROPIC_API_KEY="sk-ant-..."

Expected result: npm install finishes with no errors and the three packages appear in node_modules.

2

Define the read-only tool: lookupBooking

Define lookupBooking with createTool: an id, a description the agent reads to decide when to call it, a Zod inputSchema and outputSchema, and an execute function. This tool is safe to let the agent call on every turn since it only reads data from your booking store.

typescript
1import { createTool } from '@mastra/core/tools';
2import { z } from 'zod';
3
4const BOOKINGS: Record<string, { destination: string; checkIn: string; total: number }> = {
5 'TRV-5521': { destination: 'Lisbon', checkIn: '2026-12-10', total: 640 },
6};
7
8const lookupBooking = createTool({
9 id: 'lookup-booking',
10 description: 'Look up a travel booking by confirmation code. Returns destination, check-in date, and total paid.',
11 inputSchema: z.object({ confirmationCode: z.string() }),
12 outputSchema: z.object({
13 destination: z.string(),
14 checkIn: z.string(),
15 total: z.number(),
16 }).or(z.object({ error: z.string() })),
17 execute: async ({ context }) => {
18 const booking = BOOKINGS[context.confirmationCode];
19 if (!booking) return { error: `No booking found for ${context.confirmationCode}` };
20 return booking;
21 },
22});

Expected result: A tool object importable and callable on its own for a quick test before you wire it into an agent.

3

Define the sensitive tool: cancelBooking

cancelBooking follows the same createTool shape but takes confirmationCode and reason. Keep the handler dumb: it just performs the cancellation when called. It's never attached to the agent directly; it's only ever invoked from inside the approval Workflow step you'll build in step 5.

typescript
1const cancelBooking = createTool({
2 id: 'cancel-booking',
3 description: 'Cancel a travel booking and trigger a refund. Only called after human approval.',
4 inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
5 outputSchema: z.object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() }),
6 execute: async ({ context }) => {
7 const booking = BOOKINGS[context.confirmationCode];
8 // In production this calls your booking provider's cancellation + refund API
9 return { cancelled: true, confirmationCode: context.confirmationCode, refunded: booking?.total ?? 0 };
10 },
11});

Expected result: A second tool, same shape as the first, that the Workflow step will call directly once a human approves.

4

Build the Agent with Memory so it keeps booking context

Create the Agent with instructions, a model string, lookupBooking in its tools (cancelBooking is deliberately left out), and a Memory instance. Pass matching resource and thread identifiers on every call so the agent recalls the booking it just looked up when the customer later says 'cancel that one'.

typescript
1import { Agent } from '@mastra/core/agent';
2import { Memory } from '@mastra/memory';
3
4const travelAgent = new Agent({
5 id: 'travel-agent',
6 name: 'Travel Support Agent',
7 instructions:
8 'You are a support agent for a travel booking site. Always call lookupBooking before making any ' +
9 'claim about a reservation. You cannot cancel bookings yourself; if a customer wants to cancel, ' +
10 'say you are escalating it for approval.',
11 model: 'anthropic/claude-sonnet-5-5',
12 tools: { lookupBooking },
13 memory: new Memory({ options: { lastMessages: 20 } }),
14});

Expected result: An agent that answers booking questions on its own and defers cancellation requests instead of pretending to handle them.

5

Gate cancellation behind a suspend/resume Workflow step

A Workflow step can suspend mid-run and wait for human input, resuming later with matching resumeData. Build a workflow with one step: it checks resumeData for an approval decision; if none is present yet, it calls suspend({}) and the run pauses. Once resumed with { approved: true }, it calls cancelBooking.execute() directly, outside the agent, so the cancellation only ever happens on an explicit human decision.

typescript
1import { createWorkflow, createStep } from '@mastra/core/workflows';
2
3const approveCancellation = createStep({
4 id: 'approve-cancellation',
5 inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
6 resumeSchema: z.object({ approved: z.boolean() }),
7 outputSchema: z.object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() }).or(
8 z.object({ denied: z.literal(true) })
9 ),
10 execute: async ({ inputData, resumeData, suspend }) => {
11 if (resumeData?.approved === undefined) {
12 return await suspend({});
13 }
14 if (!resumeData.approved) return { denied: true as const };
15 return cancelBooking.execute({ context: inputData });
16 },
17});
18
19export const cancellationWorkflow = createWorkflow({
20 id: 'cancellation-workflow',
21 inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
22 outputSchema: z.object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() }).or(
23 z.object({ denied: z.literal(true) })
24 ),
25}).then(approveCancellation).commit();

Expected result: Starting the workflow suspends immediately; resuming it with { approved: true } runs the cancellation and returns the refund amount.

6

Harden it: real APIs and an audit trail

Two changes separate this from a toy: replace the in-memory BOOKINGS lookup and the fake cancellation with real calls to your booking provider's API inside each tool's execute (catch failures and return an error field instead of throwing, so the agent or workflow can react). Then log every suspend and every resume, including who approved or denied it and when, to your observability stack; that log matters more than the approval UI itself when someone asks why a refund went out.

typescript
1execute: async ({ inputData, resumeData, suspend }) => {
2 if (resumeData?.approved === undefined) {
3 console.log(`[SUSPEND] cancellation pending for ${inputData.confirmationCode}: ${inputData.reason}`);
4 return await suspend({});
5 }
6 console.log(`[RESUME] ${inputData.confirmationCode} ${resumeData.approved ? 'APPROVED' : 'DENIED'}`);
7 if (!resumeData.approved) return { denied: true as const };
8 try {
9 return await cancelBooking.execute({ context: inputData });
10 } catch (err) {
11 return { denied: true as const };
12 }
13}

Expected result: A log line for every suspend and resume, and a handler that degrades gracefully instead of throwing when the real API call fails.

Complete working example

travel-agent.ts
1/**
2 * Travel agent: looks up bookings freely, cancels only through a suspend/resume
3 * approval workflow. Run: npx tsx travel-agent.ts
4 * (npm install @mastra/core @mastra/memory @mastra/libsql zod; set ANTHROPIC_API_KEY)
5 */
6import { Agent } from '@mastra/core/agent';
7import { Memory } from '@mastra/memory';
8import { createTool } from '@mastra/core/tools';
9import { createWorkflow, createStep } from '@mastra/core/workflows';
10import { z } from 'zod';
11
12const BOOKINGS: Record<string, { destination: string; checkIn: string; total: number }> = {
13 'TRV-5521': { destination: 'Lisbon', checkIn: '2026-12-10', total: 640 },
14};
15
16const lookupBooking = createTool({
17 id: 'lookup-booking',
18 description: 'Look up a travel booking by confirmation code. Returns destination, check-in date, and total paid.',
19 inputSchema: z.object({ confirmationCode: z.string() }),
20 outputSchema: z
21 .object({ destination: z.string(), checkIn: z.string(), total: z.number() })
22 .or(z.object({ error: z.string() })),
23 execute: async ({ context }) => {
24 const booking = BOOKINGS[context.confirmationCode];
25 if (!booking) return { error: `No booking found for ${context.confirmationCode}` };
26 return booking;
27 },
28});
29
30const cancelBooking = createTool({
31 id: 'cancel-booking',
32 description: 'Cancel a travel booking and trigger a refund. Only called after human approval.',
33 inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
34 outputSchema: z.object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() }),
35 execute: async ({ context }) => {
36 const booking = BOOKINGS[context.confirmationCode];
37 return { cancelled: true, confirmationCode: context.confirmationCode, refunded: booking?.total ?? 0 };
38 },
39});
40
41const travelAgent = new Agent({
42 id: 'travel-agent',
43 name: 'Travel Support Agent',
44 instructions:
45 'You are a support agent for a travel booking site. Always call lookupBooking before making any ' +
46 'claim about a reservation. You cannot cancel bookings yourself; if a customer wants to cancel, ' +
47 'say you are escalating it for approval.',
48 model: 'anthropic/claude-sonnet-5-5',
49 tools: { lookupBooking },
50 memory: new Memory({ options: { lastMessages: 20 } }),
51});
52
53const approveCancellation = createStep({
54 id: 'approve-cancellation',
55 inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
56 resumeSchema: z.object({ approved: z.boolean() }),
57 outputSchema: z
58 .object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() })
59 .or(z.object({ denied: z.literal(true) })),
60 execute: async ({ inputData, resumeData, suspend }) => {
61 if (resumeData?.approved === undefined) {
62 console.log(`[SUSPEND] cancellation pending for ${inputData.confirmationCode}: ${inputData.reason}`);
63 return await suspend({});
64 }
65 console.log(`[RESUME] ${inputData.confirmationCode} ${resumeData.approved ? 'APPROVED' : 'DENIED'}`);
66 if (!resumeData.approved) return { denied: true as const };
67 return cancelBooking.execute({ context: inputData });
68 },
69});
70
71const cancellationWorkflow = createWorkflow({
72 id: 'cancellation-workflow',
73 inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
74 outputSchema: z
75 .object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() })
76 .or(z.object({ denied: z.literal(true) })),
77})
78 .then(approveCancellation)
79 .commit();
80
81async function main() {
82 const memoryOpts = { memory: { resource: 'cust-204', thread: 'conv-204' } };
83
84 const first = await travelAgent.generate('Can you check booking TRV-5521 for me?', memoryOpts);
85 console.log(`Agent: ${first.text}`);
86
87 const run = await cancellationWorkflow.createRun();
88 const suspended = await run.start({
89 inputData: { confirmationCode: 'TRV-5521', reason: 'Trip dates changed' },
90 });
91 console.log('Workflow suspended, awaiting human approval...');
92
93 const resumed = await run.resume({
94 step: approveCancellation,
95 resumeData: { approved: true },
96 });
97 console.log('Workflow result:', resumed);
98}
99
100main();
101

Common mistakes

Why it's a problem: Attaching cancelBooking directly to the agent's tools map 'to keep it simple'

How to avoid: Any tool on the agent can be called autonomously. Keep destructive tools out of the agent entirely and only reachable through a Workflow step that requires suspend/resume.

Why it's a problem: Forgetting to pass matching resource and thread identifiers on every agent.generate() call

How to avoid: Without identical resource/thread values across turns, Memory starts a new, empty history each time and 'that booking' won't resolve.

Why it's a problem: Calling suspend() unconditionally instead of checking resumeData first

How to avoid: A step checks whether resumeData already carries a decision before suspending again. Otherwise a resumed run suspends immediately a second time.

Why it's a problem: Letting a tool's execute() throw on a failed API call instead of returning an error field

How to avoid: An uncaught exception crashes the run. Catch it and return a structured result so the agent or the next workflow step can react.

Why it's a problem: Testing only the agent's text replies and never actually running the suspend/resume cycle

How to avoid: Run the workflow to the suspended state and call resume() with both approved: true and approved: false before shipping. The pause is the feature being tested, not a side effect.

Best practices

  • Keep destructive tools out of the Agent's tools map; route them only through a Workflow step gated by suspend/resume.
  • Write each tool's description as if explaining it to a new hire: when the model should call it, what it returns.
  • Pass the same resource and thread identifiers on every turn of a conversation so Memory actually accumulates context.
  • Define resumeSchema explicitly on every step that can suspend so the resume payload is type-checked, not a loose object.
  • Log every suspend and resume, including the decision and who made it, independent of whether the action was approved.
  • Use workflows for anything with explicit, known steps (approval, multi-stage onboarding); reserve the agent loop for open-ended reasoning.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a Mastra Agent with a lookupBooking tool and Memory, plus a separate Workflow with a suspend/resume approveCancellation step that calls cancelBooking (code below). Add a second workflow step after approval that sends a cancellation confirmation email, and make the whole workflow resumable if the email step fails. [paste complete_code]

Build AI Agents Prompt

In the Mastra Studio dev playground, open the cancellation-workflow graph and ask the assistant: 'Add a step before approve-cancellation that checks if the booking's check-in date is within 48 hours, and route those to a different, stricter approval path.'

Frequently asked questions

What's the difference between a Mastra Agent and a Mastra Workflow?

An Agent is open-ended: you give it tools and instructions and let the model decide what to call and when. A Workflow is explicit: you define the steps and the edges between them yourself. This guide uses an Agent for the parts that need judgment (reading a booking, deciding what the customer wants) and a Workflow for the part that needs a guaranteed pause (the approval gate).

How do I build an AI agent with Mastra if I've never used a TypeScript agent framework before?

Start with npm install @mastra/core, define one or two tools with createTool, and pass them into new Agent({ model, tools, instructions }). Call agent.generate('your prompt') to test it before adding Memory or a Workflow on top, exactly in the order this guide builds them.

Does Mastra's Memory persist across process restarts?

Only if you configure a persistent storage adapter (LibSQLStore pointed at a file or Turso database, for example) on your Mastra instance. The in-memory default used for quick local testing resets every time the process restarts.

Can a suspended Mastra workflow run resume days later?

Yes, as long as the workflow's run state is persisted to durable storage rather than kept only in process memory. Call run.resume() with the step and resumeData whenever the human decision arrives, even if that's in a separate process from the one that started the run.

What does running this cost?

Token cost follows the model you choose: on Claude Sonnet 5.5 ($2/M input, $10/M output), a short booking lookup plus a cancellation exchange runs well under $0.05 uncached. Mastra itself adds no per-request fee; you pay for your model provider and whatever storage backs Memory and Workflow state.

Can someone build this for me instead of doing it myself?

Yes. RapidDev builds production agents on Mastra, the Vercel AI SDK, and the Claude Agent SDK, including the approval-workflow and audit-trail pieces most tutorials skip. A free 30-minute consultation is the fastest way to scope your version.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.