9 min read

Build a Real AI Agent in TypeScript with Mastra

By Johnny Hawley

Build a Real AI Agent in TypeScript with Mastra

Most things people call an "AI agent" are a chatbot with extra steps. You type, the model talks back, and nothing in the world changes. That's a demo, not an agent.

The thing that makes an agent an agent is that it can do something: look a customer up, hit your database, file the ticket, send the email. The model decides; a tool acts. Everything else is plumbing.

So instead of the usual toy, let's build something shaped like real work: a support-triage agent. A customer message comes in — "I've been charged twice and nobody's responded" — and the agent pulls that customer's account and open tickets from your support system, then hands back a decision your software can act on: how urgent it is, whether it's a known issue, a draft reply, and whether a human needs to step in.

It takes about fifteen minutes, and it's built with Mastra. The lookup tool here is stubbed with realistic data so you can see the shape the agent reasons over — point that one function at your own Zendesk, Intercom, or Postgres and the rest of the agent doesn't change.

What you'll build

One agent, one tool, one output schema — three small files. A message arrives, the agent decides it needs the customer's history, calls the tool, and returns a typed triage decision instead of a paragraph of chat. No framework magic you can't see.

Set up the project

Start in an empty folder. Mastra runs on @mastra/core plus zod for schemas; the mastra CLI gives you a dev server.

Terminal
npm install @mastra/core@latest zod@^4
npm install -D mastra@latest typescript @types/node

Add your model provider's key to a .env file. I'm using Claude here, but Mastra speaks to every major provider — the only thing that changes is the key and the model string.

.env
ANTHROPIC_API_KEY=sk-ant-...

And one script so you can run the dev server:

package.json
{
  "scripts": {
    "dev": "mastra dev"
  }
}

Step 1 — Give the agent hands (the tool)

The tool is the only part that touches your systems, so it's where I always start. A Mastra tool is a typed function: a zod schema in, your logic, a zod schema out. The schemas aren't bureaucracy — they're what lets the model call the tool reliably, and what stops a malformed response from silently flowing into the agent's reasoning.

src/mastra/tools/lookup-customer.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
 
export const lookupCustomer = createTool({
  id: "look-up-customer",
  description:
    "Look up a customer by email and return their account and any open support tickets.",
  inputSchema: z.object({
    email: z.string().describe("The customer's email address"),
  }),
  outputSchema: z.object({
    found: z.boolean(),
    name: z.string().optional(),
    plan: z.string().optional(),
    openTickets: z.array(
      z.object({
        id: z.string(),
        subject: z.string(),
        ageDays: z.number(),
      }),
    ),
  }),
  // The validated input arrives as the first argument.
  execute: async ({ email }) => {
    // In production this is one call to your support system —
    // Zendesk, Intercom, a Postgres query, an internal API:
    //
    //   const res = await fetch(`${SUPPORT_API}/customers?email=${email}`, {
    //     headers: { authorization: `Bearer ${process.env.SUPPORT_API_KEY}` },
    //   }).then((r) => r.json());
    //
    // Stubbed here so you can see the SHAPE the agent reasons over:
    return {
      found: true,
      name: "Dana Okafor",
      plan: "Team (annual)",
      openTickets: [
        { id: "T-4471", subject: "Duplicate charge on June invoice", ageDays: 5 },
      ],
    };
  },
});

The highlighted lines are the whole point: a tool is just a function that returns real, private data. The model never sees that code. It sees the description and the inputSchema, decides when to call it, and gets your typed result back to reason over.

Step 2 — Define the agent and the decision it makes

Now the brain. An agent is its instructions (who it is and how it behaves), a model, and the tools it's allowed to reach for. Note the model is a plain string in provider/model form — switch providers by switching the string.

The instructions are where triage actually lives. This isn't "be concise" — it's an operating policy, the same rules you'd write for a new support hire.

src/mastra/agents/triage-agent.ts
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import { lookupCustomer } from "../tools/lookup-customer";
 
export const triageAgent = new Agent({
  name: "triage-agent",
  instructions: `You triage inbound support messages. For every message,
    first call look-up-customer to pull the account and open tickets before
    deciding anything. Then:
    - priority is "urgent" for billing errors, double charges, or anything
      mentioning a chargeback or legal action; "high" if an open ticket is
      older than 3 days; otherwise "normal".
    - isKnownIssue is true only if an existing open ticket matches the complaint.
    - Write suggestedReply as a short, specific draft. Never promise a refund
      or a date you cannot verify, and never invent a ticket ID.
    - needsHuman is true when the account isn't found, when money has to move,
      or when you are unsure.`,
  model: "anthropic/claude-sonnet-4-6",
  tools: { lookupCustomer },
});
 
// The shape of the decision the agent must return. Your system consumes this —
// route by priority, autosend the reply, or open a human task.
export const triageDecision = z.object({
  priority: z.enum(["urgent", "high", "normal"]),
  category: z.string().describe("e.g. billing, shipping, account, bug"),
  isKnownIssue: z.boolean(),
  suggestedReply: z.string(),
  needsHuman: z.boolean(),
});

Those instruction lines are doing more work than they look. "Never promise a refund you cannot verify" and "needsHuman when money has to move" are the difference between an agent you can trust on the front line and one that confidently makes things up. Half of making an agent reliable is telling it what to do when it's unsure.

Step 3 — Wire it up and run it

Register the agent with a Mastra instance. This is the entry point the dev server reads.

src/mastra/index.ts
import { Mastra } from "@mastra/core";
import { triageAgent } from "./agents/triage-agent";
 
export const mastra = new Mastra({
  agents: { triageAgent },
});

Here's the full project — three files, nothing hidden:

tools/lookup-customer.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
 
export const lookupCustomer = createTool({
  id: "look-up-customer",
  description:
    "Look up a customer by email and return their account and open tickets.",
  inputSchema: z.object({ email: z.string() }),
  outputSchema: z.object({
    found: z.boolean(),
    name: z.string().optional(),
    plan: z.string().optional(),
    openTickets: z.array(
      z.object({ id: z.string(), subject: z.string(), ageDays: z.number() }),
    ),
  }),
  execute: async ({ email }) => {
    /* ...one call to your support system... */
    return {
      found: true,
      name: "Dana Okafor",
      plan: "Team (annual)",
      openTickets: [
        { id: "T-4471", subject: "Duplicate charge on June invoice", ageDays: 5 },
      ],
    };
  },
});

Start the dev server and open Mastra Studio:

Terminal
npm run dev

Studio opens at localhost:4111. Paste in "I've been charged twice for my subscription and nobody has responded. Account: dana@acme.io" and watch the trace: the model pulls the email out of the message, calls look-up-customer, sees the five-day-old duplicate-charge ticket, and reasons from that instead of guessing.

To run it from code — the way your app actually would — ask the agent for the typed decision, not a chat reply:

run.ts
import { triageAgent, triageDecision } from "./src/mastra/agents/triage-agent";
 
const message =
  "I've been charged twice for my subscription and nobody has " +
  "responded to my email. Account: dana@acme.io";
 
const res = await triageAgent.generate(message, {
  structuredOutput: {
    schema: triageDecision,
    // Passing the model here lets the agent call the tool AND
    // return typed output in a single pass.
    model: "anthropic/claude-sonnet-4-6",
  },
});
 
console.log(res.object);
// {
//   priority: "urgent",
//   category: "billing",
//   isKnownIssue: true,
//   suggestedReply: "Hi Dana — I can see the duplicate charge on your June
//                    invoice (ticket T-4471) and I've escalated it...",
//   needsHuman: true,
// }

res.object is typed and validated against your schema. That's the difference that matters: you don't get a sentence to copy-paste, you get a value your code can branch on — route by priority, autosend when needsHuman is false, open a human task when it isn't.

What you actually built

Strip away the framing and you built the one pattern every real agent shares: the model reasons, a typed tool acts, and the result flows back — as a decision your system can run on, not a paragraph a human re-reads. The stubbed lookup was a placeholder. The day you point that execute function at your real support system is the day it stops being a demo and starts deflecting tickets.

What this example doesn't have is everything that makes an agent survive contact with real users: evals so you know when it regresses, guardrails for when the tool returns garbage, retries, observability, auth. That gap — between a thing that runs on your laptop and a thing that ships — is the hard part, and it's the part most teams underestimate.

That's the part we do. If you've got a process that an agent like this could own, a one-week Code Sprint is the fastest way to find out whether it's real, with working software in your hands at the end of it, and you owning every line.