Building a production AI agent in TypeScript with Mastra: a 2026 step-by-step.
I spent an afternoon last month wiring up an AI agent in raw TypeScript using the Anthropic SDK directly. The code worked, but I owned every piece of it: the tool dispatch loop, the conversation history array, the retry logic. Around 400 lines before the agent did anything interesting.
Mastra cuts that to about 60. A TypeScript-first agent framework with 24k+ GitHub stars, an active release cadence (88 releases as of May 2026), and a model router that talks to 40+ providers through one API. This tutorial goes from zero to a running agent with a custom tool and persistent memory. All code in this article runs.
TL;DR
| Step | What you build | Time |
|---|---|---|
| Install | Scaffolded project with Mastra wired in | 5 min |
| Agent | An agent with a system prompt and a model | 10 min |
| Tool | A custom tool the agent calls | 15 min |
| Memory | Conversation history across sessions | 10 min |
| Deploy | A running HTTP server | 5 min |
1. Where Mastra sits in the stack
Raw SDK calls give you full control but you write the orchestration layer yourself: the tool call loop, history management, error handling, retries. Frameworks like LangChain and LlamaIndex solve this but lean heavily Python-first; the TypeScript ports lag the Python versions.
Mastra starts from TypeScript. The primitives map directly to what TypeScript developers already know: classes, Zod schemas, async functions. No port-lag exists because the framework itself is the TypeScript version.
The trade-off is the same as any framework: you trade control for speed. For prototypes and most production agents, the trade is worth it. For cases where the framework's tool dispatch or memory behavior does not match your exact needs, you can always drop a layer and call the Vercel AI SDK directly, which Mastra wraps under the hood.
2. Project setup
Mastra's scaffolder creates everything you need:
npm create mastra@latest
The wizard asks for a project name, model provider, and whether you want the starter example files. Pick your provider (OpenAI, Anthropic, Google, or any of the 40+ supported). For this tutorial I'll use Anthropic.
After the wizard finishes:
cd my-agent
npm install
Open .env and add your key:
ANTHROPIC_API_KEY=sk-ant-...
The generated project structure looks like this:
src/
mastra/
index.ts # Mastra instance
agents/
weather-agent.ts
tools/
weather-tool.ts
You can rename or replace the starter files. The root entry point src/mastra/index.ts registers your agents and tools with the Mastra runtime:
// src/mastra/index.ts
import { Mastra } from "@mastra/core";
import { supportAgent } from "./agents/support-agent";
export const mastra = new Mastra({
agents: { supportAgent },
});
That is the full configuration. No YAML, no config files, no factory functions to memorize.
3. Defining your first agent
An agent in Mastra is an instance of the Agent class. You give it an id, a name, a model, and instructions. The instructions are the system prompt.
// src/mastra/agents/support-agent.ts
import { Agent } from "@mastra/core/agent";
export const supportAgent = new Agent({
id: "support-agent",
name: "Support Agent",
model: "anthropic/claude-sonnet-4-6-20250929",
instructions: `You are a support agent for a SaaS product.
Answer questions clearly. If you do not know the answer, say so.
When the user describes a bug, ask for their account ID and browser version before troubleshooting.
Keep responses under 150 words unless the user asks for detail.`,
});
The model field uses Mastra's router format: provider/model-id. This means swapping from Anthropic to OpenAI is a one-line change. Pin the model ID to a dated version, not an alias, so you own when it upgrades.
To call the agent from your application code:
import { mastra } from "./mastra";
const agent = mastra.getAgentById("support-agent");
const response = await agent.generate("My dashboard stopped loading after your last update.");
console.log(response.text);
generate returns a full response after the model finishes. If you want streaming for a chat UI, swap to stream:
const stream = await agent.stream("What does the onboarding flow look like?");
for await (const chunk of stream.textStream) {
process.stdout.write(chunk);
}
Both methods return { text, toolCalls, toolResults, usage }. The usage object gives you token counts per call, which you should log in production.
4. Adding a custom tool
Tools are how agents take action beyond text generation. A tool has an id, a description the model uses to decide when to call it, an inputSchema validated by Zod, and an execute function.
Here is a tool that looks up an account by ID from a hypothetical database:
// src/mastra/tools/account-lookup.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const accountLookupTool = createTool({
id: "account-lookup",
description: "Look up a customer account by their account ID. Returns plan, status, and creation date.",
inputSchema: z.object({
accountId: z.string().describe("The customer account ID, format ACC-XXXXXXXX"),
}),
execute: async ({ context }) => {
const { accountId } = context;
// Replace with your real DB call
const account = await fetchAccountFromDb(accountId);
if (!account) {
return { found: false, accountId };
}
return {
found: true,
accountId,
plan: account.plan,
status: account.status,
createdAt: account.createdAt,
};
},
});
async function fetchAccountFromDb(accountId: string) {
// Stub — wire to your actual data layer
if (accountId === "ACC-00000001") {
return { plan: "pro", status: "active", createdAt: "2025-03-15" };
}
return null;
}
Attach the tool to the agent by adding it to the tools array:
// src/mastra/agents/support-agent.ts
import { Agent } from "@mastra/core/agent";
import { accountLookupTool } from "../tools/account-lookup";
export const supportAgent = new Agent({
id: "support-agent",
name: "Support Agent",
model: "anthropic/claude-sonnet-4-6-20250929",
instructions: `You are a support agent for a SaaS product.
When a user provides an account ID, use the account-lookup tool to retrieve their account details before answering.
Answer questions clearly. If you do not know the answer, say so.`,
tools: {
"account-lookup": accountLookupTool,
},
});
The model now sees the tool in its context. When a user says "My account ACC-00000001 is showing the wrong plan", the agent calls account-lookup, gets the account record back, and includes it in its reasoning before responding. You see the tool call in response.toolCalls and the result in response.toolResults.
The Zod schema does two things: it tells the model what the tool expects (the description and field names show up in the model's tool spec), and it validates the model's output before your execute function runs. If the model sends a malformed accountId, Mastra rejects the call before it hits your code.
5. Adding memory
Without memory, every call to agent.generate starts from a blank slate. The agent does not know what the user said two messages ago. For a support agent this breaks multi-turn conversations immediately.
Mastra's @mastra/memory package adds three layers: message history (the last N turns), working memory (key facts extracted and stored between turns), and semantic recall (vector search over past conversations). For most agents, message history is enough to start.
Install the package and a storage backend:
npm install @mastra/memory @mastra/libsql
Wire memory into the agent:
// src/mastra/agents/support-agent.ts
import { Agent } from "@mastra/core/agent";
import { Memory } from "@mastra/memory";
import { LibSQLStore } from "@mastra/libsql";
import { accountLookupTool } from "../tools/account-lookup";
const memory = new Memory({
storage: new LibSQLStore({
url: process.env.DATABASE_URL ?? "file:./agent.db",
}),
options: {
lastMessages: 20,
},
});
export const supportAgent = new Agent({
id: "support-agent",
name: "Support Agent",
model: "anthropic/claude-sonnet-4-6-20250929",
instructions: `You are a support agent for a SaaS product.
When a user provides an account ID, use the account-lookup tool before answering.
Keep responses under 150 words unless the user asks for detail.`,
tools: {
"account-lookup": accountLookupTool,
},
memory,
});
Pass a resource and thread when you call the agent. resource identifies the user; thread isolates a specific conversation:
const response = await agent.generate(
"What plan am I on?",
{
memory: {
resource: "user-42",
thread: "support-session-2026-05-25",
},
},
);
On the first turn the agent has no history. On the second turn it gets the last 20 messages from that resource and thread. The user can say "follow up from earlier" and the agent knows what earlier means.
LibSQL writes to a local SQLite file in development. For production, point DATABASE_URL at a Turso connection string and the same code writes to a distributed SQLite database with no other changes.
6. Running locally and deploying
Development server:
npx mastra dev
This starts a local server with a REST API over your agents and a browser-based studio on port 4111 where you can send messages to the agent, inspect tool calls, and read the memory state. The studio is useful enough that I use it even for non-UI agents.
For production you have two options.
The first is the Mastra cloud deploy, which packages your agents as a managed service:
npx mastra deploy
The second is self-hosting. Add @mastra/express and mount the Mastra server into your existing Express app:
npm install @mastra/express
// src/index.ts
import express from "express";
import { MastraServer } from "@mastra/express";
import { mastra } from "./mastra";
const app = express();
app.use(express.json());
const server = new MastraServer({ app, mastra });
await server.init();
app.listen(process.env.PORT ?? 3000);
MastraServer.init() registers the agent endpoints under /api. Your agents become callable over HTTP with no extra routing code. Deploy this to Railway, Fly.io, or any Node.js host that accepts a Dockerfile.
The bottom line
Mastra removes the orchestration scaffolding that accounts for the first 300 lines of every TypeScript agent project. The three hours I spent on tool dispatch and conversation history management with raw SDK calls collapse to about 15 minutes with Mastra.
The framework makes a bet on TypeScript as the language where agents actually ship to production, not just where they get prototyped. That bet reflects what I see in production codebases. Python still dominates training and research. TypeScript dominates the web services those agents plug into.
The main thing I'd add to the stack described above: log response.usage on every generate call so you can see what the agent actually costs per session in production. The number surprises most teams the first week.
What does your agent stack look like right now? Raw SDK, a framework, something in-house? Curious what the pressure points are.
GDS K S · thegdsks.com · follow on X @thegdsks
The orchestration layer is the part nobody talks about until they've rewritten it twice.
Top comments (1)
The production part is where most agent projects become real.
A demo can work with a single happy path, but production needs memory boundaries, retry behavior, tool permissions, observability, and a way to explain why the agent took an action. The framework matters, but the operating model matters just as much.
I like seeing agent tutorials move past prompts and into deployment concerns.