Tutorial
Mastra, Part 1: Agents
The first part of a hands-on series on Mastra. I start where everything else builds from — defining a typed agent, giving it tools with createTool, and wiring memory so it remembers across turns.

I've built agents from scratch — the loop, the message list, the stop condition, all by hand. It's the best way to understand what an agent is. But once you understand it, hand-rolling the same plumbing for every project gets old fast.
Mastra is a TypeScript framework that gives you those primitives — agents, tools, memory, workflows, and a runtime to host them — without hiding how they work. This series walks through it in three parts, and they build on each other:
This series
- Agents (you're here) — define an agent, give it tools, add memory.
- Workflows — orchestrate multi-step logic.
- The Harness — the runtime that hosts it all.
- Streaming — get the work to a UI live.
- RAG — answer from real documents.
- Durable agents — survive crashes, run in the background.
- Evals — prove the agent is actually good.
Everything here uses Mastra 1.46+ (@mastra/core). I'll keep the code
runnable and minimal, and link the official docs as I go.
What an agent is in Mastra
In Mastra, an agent is a configured instance of the Agent class. You give it
instructions, a model, and — optionally — tools, memory, and more. Mastra owns
the loop; you describe the behavior.
Step 1: Define an agent
Start with the smallest thing that works. An agent needs a name, instructions, and a model:
import { Agent } from "@mastra/core/agent";
export const assistant = new Agent({
name: "Assistant",
instructions: "You are a concise, friendly assistant. Prefer short answers.",
model: "openai/gpt-5.5",
});Two things worth noting right away:
instructionsis your system prompt. It's the single most important field — it's where the agent's personality and rules live. Write it for the model.modelis a string inprovider/model-idform. Mastra resolves the provider for you, so swapping models is a one-line change.
To actually run it, register the agent with a Mastra instance — this is the
root object that holds everything your app exposes:
import { Mastra } from "@mastra/core";
import { assistant } from "../agents/assistant";
export const mastra = new Mastra({
agents: { assistant },
});Now you can pull the agent back out and talk to it:
import { mastra } from "./mastra";
const agent = mastra.getAgent("assistant");
const result = await agent.generate("What's the capital of Portugal?");
console.log(result.text); // "Lisbon."That generate call is the whole agent loop in one method. With no tools, it's
a single model call. The moment you add a tool, that same loop starts doing real
work.
Step 2: Give it a tool
A tool is a function the model can call, described well enough that the model
knows when to call it. Mastra's createTool wraps your function with a schema
on both sides — input and output:
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const weatherTool = createTool({
id: "get-weather",
description: "Get the current weather for a given city.",
inputSchema: z.object({
city: z.string().describe("The city name, e.g. 'Lisbon'"),
}),
outputSchema: z.object({
city: z.string(),
celsius: z.number(),
}),
execute: async ({ city }) => {
// A real tool hits an API. We'll fake it.
const temps: Record<string, number> = { Lisbon: 22, Oslo: 4, Cairo: 35 };
return { city, celsius: temps[city] ?? 18 };
},
});The pieces that matter, and they're the same in every framework I've used:
descriptionis a prompt. The model decides whether to call your tool based purely on this sentence. "Get the current weather for a given city" beats "weather fn".inputSchemais a contract. The model must produce arguments that match it. Mastra validates them with Zod before yourexecuteruns, so bad input never reaches your code.outputSchemadoes the same on the way back, and gives downstream steps a typed shape to rely on.
Attach it to the agent by passing a tools map:
import { Agent } from "@mastra/core/agent";
import { weatherTool } from "../tools/weather";
export const assistant = new Agent({
name: "Assistant",
instructions: "You are a helpful assistant. Use tools when they help.",
model: "openai/gpt-5.5",
tools: { weatherTool },
});Now ask it something that needs the tool, and watch the loop chain calls on its own:
const result = await agent.generate("Should I pack a coat for Oslo?");
console.log(result.text);
// "It's about 4°C in Oslo, so yes — bring a coat."You never wrote the loop. The model planned the order — call the tool, read the result, then answer — and Mastra drove it. That's the payoff of letting the framework own the loop: you describe capabilities, not control flow.
Spend your effort on tool id, description, and parameter names. The model
can't see your implementation — only the schema. Half of "prompt engineering"
for agents is really just naming things well.
Step 3: Add memory
So far each generate call is amnesiac — the agent forgets everything the
moment it answers. To hold a conversation, the agent needs memory: a place
to store and recall messages across turns.
Mastra splits this into the memory module and a storage backend behind it. For local development, LibSQL (SQLite) is the simplest:
import { Agent } from "@mastra/core/agent";
import { Memory } from "@mastra/memory";
import { LibSQLStore } from "@mastra/libsql";
import { weatherTool } from "../tools/weather";
export const assistant = new Agent({
name: "Assistant",
instructions: "You are a helpful assistant. Use tools when they help.",
model: "openai/gpt-5.5",
tools: { weatherTool },
memory: new Memory({
storage: new LibSQLStore({ url: "file:./memory.db" }),
}),
});Memory in Mastra is scoped by thread and resource — a thread is one conversation, a resource is usually one user. You pass those when you call the agent, and Mastra handles loading prior messages and saving new ones:
const memoryScope = {
memory: { thread: "trip-planning", resource: "user-42" },
};
await agent.generate("What's the weather in Cairo?", memoryScope);
// Later — same thread — the agent still has the context:
const result = await agent.generate("Is that warmer than Oslo?", memoryScope);
console.log(result.text);
// "Yes — Cairo is about 35°C versus Oslo's 4°C."The second call works because of memory. "Is that warmer than Oslo?" only makes sense if "that" still points at Cairo — and it does, because the first turn's messages were persisted to the thread and replayed into the second.
Memory is per-thread on purpose. If two users share a thread/resource, they'll see each other's history. Scope threads to a conversation and resources to a user, and nothing leaks between them.
Mastra's memory does more than replay the last N messages — it supports working memory, semantic recall, and memory processors for trimming long histories. But the model above — a thread, a resource, a storage backend — is the load-bearing idea. Everything else is a refinement.
What you've got
In three small steps you built a real agent:
- an
Agentwith instructions and a model, - a typed tool via
createToolthat the model calls on its own, - memory that gives it continuity across turns.
That's a complete, useful agent. But a single agent is still just a loop around a model. The interesting systems come from orchestrating logic — running steps in sequence, branching on results, looping until done — with guarantees an agent loop alone can't give you.
That's Part 2: Workflows, where I build multi-step pipelines and then hand control between workflows and agents.