# How to Build an AI Agent with Mastra

- Tool: Mastra
- Difficulty: Intermediate
- Time required: 45-60 min
- Compatibility: Node.js 20+, TypeScript, @mastra/core (v1.x), any OS
- Last updated: November 2026

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

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

## Before you start

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

```
npm install @mastra/core @mastra/memory @mastra/libsql zod
export 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.

```
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';

const BOOKINGS: Record<string, { destination: string; checkIn: string; total: number }> = {
  'TRV-5521': { destination: 'Lisbon', checkIn: '2026-12-10', total: 640 },
};

const lookupBooking = createTool({
  id: 'lookup-booking',
  description: 'Look up a travel booking by confirmation code. Returns destination, check-in date, and total paid.',
  inputSchema: z.object({ confirmationCode: z.string() }),
  outputSchema: z.object({
    destination: z.string(),
    checkIn: z.string(),
    total: z.number(),
  }).or(z.object({ error: z.string() })),
  execute: async ({ context }) => {
    const booking = BOOKINGS[context.confirmationCode];
    if (!booking) return { error: `No booking found for ${context.confirmationCode}` };
    return booking;
  },
});
```

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

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

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

```
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';

const travelAgent = new Agent({
  id: 'travel-agent',
  name: 'Travel Support Agent',
  instructions:
    'You are a support agent for a travel booking site. Always call lookupBooking before making any ' +
    'claim about a reservation. You cannot cancel bookings yourself; if a customer wants to cancel, ' +
    'say you are escalating it for approval.',
  model: 'anthropic/claude-sonnet-5-5',
  tools: { lookupBooking },
  memory: new Memory({ options: { lastMessages: 20 } }),
});
```

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

```
import { createWorkflow, createStep } from '@mastra/core/workflows';

const approveCancellation = createStep({
  id: 'approve-cancellation',
  inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
  resumeSchema: z.object({ approved: z.boolean() }),
  outputSchema: z.object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() }).or(
    z.object({ denied: z.literal(true) })
  ),
  execute: async ({ inputData, resumeData, suspend }) => {
    if (resumeData?.approved === undefined) {
      return await suspend({});
    }
    if (!resumeData.approved) return { denied: true as const };
    return cancelBooking.execute({ context: inputData });
  },
});

export const cancellationWorkflow = createWorkflow({
  id: 'cancellation-workflow',
  inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
  outputSchema: z.object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() }).or(
    z.object({ denied: z.literal(true) })
  ),
}).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.

```
execute: async ({ inputData, resumeData, suspend }) => {
  if (resumeData?.approved === undefined) {
    console.log(`[SUSPEND] cancellation pending for ${inputData.confirmationCode}: ${inputData.reason}`);
    return await suspend({});
  }
  console.log(`[RESUME] ${inputData.confirmationCode} ${resumeData.approved ? 'APPROVED' : 'DENIED'}`);
  if (!resumeData.approved) return { denied: true as const };
  try {
    return await cancelBooking.execute({ context: inputData });
  } catch (err) {
    return { denied: true as const };
  }
}
```

**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 code example

File: `travel-agent.ts`

```typescript
/**
 * Travel agent: looks up bookings freely, cancels only through a suspend/resume
 * approval workflow. Run: npx tsx travel-agent.ts
 * (npm install @mastra/core @mastra/memory @mastra/libsql zod; set ANTHROPIC_API_KEY)
 */
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { createTool } from '@mastra/core/tools';
import { createWorkflow, createStep } from '@mastra/core/workflows';
import { z } from 'zod';

const BOOKINGS: Record<string, { destination: string; checkIn: string; total: number }> = {
  'TRV-5521': { destination: 'Lisbon', checkIn: '2026-12-10', total: 640 },
};

const lookupBooking = createTool({
  id: 'lookup-booking',
  description: 'Look up a travel booking by confirmation code. Returns destination, check-in date, and total paid.',
  inputSchema: z.object({ confirmationCode: z.string() }),
  outputSchema: z
    .object({ destination: z.string(), checkIn: z.string(), total: z.number() })
    .or(z.object({ error: z.string() })),
  execute: async ({ context }) => {
    const booking = BOOKINGS[context.confirmationCode];
    if (!booking) return { error: `No booking found for ${context.confirmationCode}` };
    return booking;
  },
});

const cancelBooking = createTool({
  id: 'cancel-booking',
  description: 'Cancel a travel booking and trigger a refund. Only called after human approval.',
  inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
  outputSchema: z.object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() }),
  execute: async ({ context }) => {
    const booking = BOOKINGS[context.confirmationCode];
    return { cancelled: true, confirmationCode: context.confirmationCode, refunded: booking?.total ?? 0 };
  },
});

const travelAgent = new Agent({
  id: 'travel-agent',
  name: 'Travel Support Agent',
  instructions:
    'You are a support agent for a travel booking site. Always call lookupBooking before making any ' +
    'claim about a reservation. You cannot cancel bookings yourself; if a customer wants to cancel, ' +
    'say you are escalating it for approval.',
  model: 'anthropic/claude-sonnet-5-5',
  tools: { lookupBooking },
  memory: new Memory({ options: { lastMessages: 20 } }),
});

const approveCancellation = createStep({
  id: 'approve-cancellation',
  inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
  resumeSchema: z.object({ approved: z.boolean() }),
  outputSchema: z
    .object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() })
    .or(z.object({ denied: z.literal(true) })),
  execute: async ({ inputData, resumeData, suspend }) => {
    if (resumeData?.approved === undefined) {
      console.log(`[SUSPEND] cancellation pending for ${inputData.confirmationCode}: ${inputData.reason}`);
      return await suspend({});
    }
    console.log(`[RESUME] ${inputData.confirmationCode} ${resumeData.approved ? 'APPROVED' : 'DENIED'}`);
    if (!resumeData.approved) return { denied: true as const };
    return cancelBooking.execute({ context: inputData });
  },
});

const cancellationWorkflow = createWorkflow({
  id: 'cancellation-workflow',
  inputSchema: z.object({ confirmationCode: z.string(), reason: z.string() }),
  outputSchema: z
    .object({ cancelled: z.boolean(), confirmationCode: z.string(), refunded: z.number() })
    .or(z.object({ denied: z.literal(true) })),
})
  .then(approveCancellation)
  .commit();

async function main() {
  const memoryOpts = { memory: { resource: 'cust-204', thread: 'conv-204' } };

  const first = await travelAgent.generate('Can you check booking TRV-5521 for me?', memoryOpts);
  console.log(`Agent: ${first.text}`);

  const run = await cancellationWorkflow.createRun();
  const suspended = await run.start({
    inputData: { confirmationCode: 'TRV-5521', reason: 'Trip dates changed' },
  });
  console.log('Workflow suspended, awaiting human approval...');

  const resumed = await run.resume({
    step: approveCancellation,
    resumeData: { approved: true },
  });
  console.log('Workflow result:', resumed);
}

main();
```

## Common mistakes

- **Attaching cancelBooking directly to the agent's tools map 'to keep it simple'** — undefined Fix: 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.
- **Forgetting to pass matching resource and thread identifiers on every agent.generate() call** — undefined Fix: Without identical resource/thread values across turns, Memory starts a new, empty history each time and 'that booking' won't resolve.
- **Calling suspend() unconditionally instead of checking resumeData first** — undefined Fix: A step checks whether resumeData already carries a decision before suspending again. Otherwise a resumed run suspends immediately a second time.
- **Letting a tool's execute() throw on a failed API call instead of returning an error field** — undefined Fix: An uncaught exception crashes the run. Catch it and return a structured result so the agent or the next workflow step can react.
- **Testing only the agent's text replies and never actually running the suspend/resume cycle** — undefined Fix: 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.

## Use cases

### undefined



### undefined



### undefined



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

---

Source: https://www.rapidevelopers.com/build-ai-agent/how-to-build-an-ai-agent-with-mastra
© RapidDev — https://www.rapidevelopers.com/build-ai-agent/how-to-build-an-ai-agent-with-mastra
