{"slug": "building-a-production-ai-agent-in-typescript-with-mastra-a-2026-step-by-step", "title": "Building a production AI agent in TypeScript with Mastra: a 2026 step-by-step.", "summary": "A developer built a production AI agent in TypeScript using the Mastra framework, reducing code from 400 lines with the raw Anthropic SDK to about 60 lines. Mastra, a TypeScript-first agent framework with 24k+ GitHub stars and 88 releases as of May 2026, provides a model router supporting 40+ providers and includes scaffolding, custom tools, and persistent memory. The tutorial demonstrates creating an agent with a system prompt, custom tool, memory, and deployment in under an hour.", "body_md": "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.\n\nMastra 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.\n\n| Step | What you build | Time |\n|---|---|---|\n| Install | Scaffolded project with Mastra wired in | 5 min |\n| Agent | An agent with a system prompt and a model | 10 min |\n| Tool | A custom tool the agent calls | 15 min |\n| Memory | Conversation history across sessions | 10 min |\n| Deploy | A running HTTP server | 5 min |\n\nRaw 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.\n\nMastra 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.\n\nThe 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.\n\nMastra's scaffolder creates everything you need:\n\n```\nnpm create mastra@latest\n```\n\nThe 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.\n\nAfter the wizard finishes:\n\n```\ncd my-agent\nnpm install\n```\n\nOpen `.env`\n\nand add your key:\n\n```\nANTHROPIC_API_KEY=sk-ant-...\n```\n\nThe generated project structure looks like this:\n\n```\nsrc/\n  mastra/\n    index.ts          # Mastra instance\n    agents/\n      weather-agent.ts\n    tools/\n      weather-tool.ts\n```\n\nYou can rename or replace the starter files. The root entry point `src/mastra/index.ts`\n\nregisters your agents and tools with the Mastra runtime:\n\n``` js\n// src/mastra/index.ts\nimport { Mastra } from \"@mastra/core\";\nimport { supportAgent } from \"./agents/support-agent\";\n\nexport const mastra = new Mastra({\n  agents: { supportAgent },\n});\n```\n\nThat is the full configuration. No YAML, no config files, no factory functions to memorize.\n\nAn agent in Mastra is an instance of the `Agent`\n\nclass. You give it an `id`\n\n, a `name`\n\n, a `model`\n\n, and `instructions`\n\n. The instructions are the system prompt.\n\n``` js\n// src/mastra/agents/support-agent.ts\nimport { Agent } from \"@mastra/core/agent\";\n\nexport const supportAgent = new Agent({\n  id: \"support-agent\",\n  name: \"Support Agent\",\n  model: \"anthropic/claude-sonnet-4-6-20250929\",\n  instructions: `You are a support agent for a SaaS product.\nAnswer questions clearly. If you do not know the answer, say so.\nWhen the user describes a bug, ask for their account ID and browser version before troubleshooting.\nKeep responses under 150 words unless the user asks for detail.`,\n});\n```\n\nThe `model`\n\nfield uses Mastra's router format: `provider/model-id`\n\n. 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.\n\nTo call the agent from your application code:\n\n``` js\nimport { mastra } from \"./mastra\";\n\nconst agent = mastra.getAgentById(\"support-agent\");\n\nconst response = await agent.generate(\"My dashboard stopped loading after your last update.\");\nconsole.log(response.text);\n```\n\n`generate`\n\nreturns a full response after the model finishes. If you want streaming for a chat UI, swap to `stream`\n\n:\n\n``` js\nconst stream = await agent.stream(\"What does the onboarding flow look like?\");\nfor await (const chunk of stream.textStream) {\n  process.stdout.write(chunk);\n}\n```\n\nBoth methods return `{ text, toolCalls, toolResults, usage }`\n\n. The `usage`\n\nobject gives you token counts per call, which you should log in production.\n\nTools are how agents take action beyond text generation. A tool has an `id`\n\n, a `description`\n\nthe model uses to decide when to call it, an `inputSchema`\n\nvalidated by Zod, and an `execute`\n\nfunction.\n\nHere is a tool that looks up an account by ID from a hypothetical database:\n\n``` js\n// src/mastra/tools/account-lookup.ts\nimport { createTool } from \"@mastra/core/tools\";\nimport { z } from \"zod\";\n\nexport const accountLookupTool = createTool({\n  id: \"account-lookup\",\n  description: \"Look up a customer account by their account ID. Returns plan, status, and creation date.\",\n  inputSchema: z.object({\n    accountId: z.string().describe(\"The customer account ID, format ACC-XXXXXXXX\"),\n  }),\n  execute: async ({ context }) => {\n    const { accountId } = context;\n    // Replace with your real DB call\n    const account = await fetchAccountFromDb(accountId);\n    if (!account) {\n      return { found: false, accountId };\n    }\n    return {\n      found: true,\n      accountId,\n      plan: account.plan,\n      status: account.status,\n      createdAt: account.createdAt,\n    };\n  },\n});\n\nasync function fetchAccountFromDb(accountId: string) {\n  // Stub — wire to your actual data layer\n  if (accountId === \"ACC-00000001\") {\n    return { plan: \"pro\", status: \"active\", createdAt: \"2025-03-15\" };\n  }\n  return null;\n}\n```\n\nAttach the tool to the agent by adding it to the `tools`\n\narray:\n\n``` js\n// src/mastra/agents/support-agent.ts\nimport { Agent } from \"@mastra/core/agent\";\nimport { accountLookupTool } from \"../tools/account-lookup\";\n\nexport const supportAgent = new Agent({\n  id: \"support-agent\",\n  name: \"Support Agent\",\n  model: \"anthropic/claude-sonnet-4-6-20250929\",\n  instructions: `You are a support agent for a SaaS product.\nWhen a user provides an account ID, use the account-lookup tool to retrieve their account details before answering.\nAnswer questions clearly. If you do not know the answer, say so.`,\n  tools: {\n    \"account-lookup\": accountLookupTool,\n  },\n});\n```\n\nThe 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`\n\n, gets the account record back, and includes it in its reasoning before responding. You see the tool call in `response.toolCalls`\n\nand the result in `response.toolResults`\n\n.\n\nThe 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`\n\nfunction runs. If the model sends a malformed `accountId`\n\n, Mastra rejects the call before it hits your code.\n\nWithout memory, every call to `agent.generate`\n\nstarts 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.\n\nMastra's `@mastra/memory`\n\npackage 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.\n\nInstall the package and a storage backend:\n\n```\nnpm install @mastra/memory @mastra/libsql\n```\n\nWire memory into the agent:\n\n``` js\n// src/mastra/agents/support-agent.ts\nimport { Agent } from \"@mastra/core/agent\";\nimport { Memory } from \"@mastra/memory\";\nimport { LibSQLStore } from \"@mastra/libsql\";\nimport { accountLookupTool } from \"../tools/account-lookup\";\n\nconst memory = new Memory({\n  storage: new LibSQLStore({\n    url: process.env.DATABASE_URL ?? \"file:./agent.db\",\n  }),\n  options: {\n    lastMessages: 20,\n  },\n});\n\nexport const supportAgent = new Agent({\n  id: \"support-agent\",\n  name: \"Support Agent\",\n  model: \"anthropic/claude-sonnet-4-6-20250929\",\n  instructions: `You are a support agent for a SaaS product.\nWhen a user provides an account ID, use the account-lookup tool before answering.\nKeep responses under 150 words unless the user asks for detail.`,\n  tools: {\n    \"account-lookup\": accountLookupTool,\n  },\n  memory,\n});\n```\n\nPass a `resource`\n\nand `thread`\n\nwhen you call the agent. `resource`\n\nidentifies the user; `thread`\n\nisolates a specific conversation:\n\n``` js\nconst response = await agent.generate(\n  \"What plan am I on?\",\n  {\n    memory: {\n      resource: \"user-42\",\n      thread: \"support-session-2026-05-25\",\n    },\n  },\n);\n```\n\nOn 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.\n\nLibSQL writes to a local SQLite file in development. For production, point `DATABASE_URL`\n\nat a Turso connection string and the same code writes to a distributed SQLite database with no other changes.\n\nDevelopment server:\n\n```\nnpx mastra dev\n```\n\nThis 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.\n\nFor production you have two options.\n\nThe first is the Mastra cloud deploy, which packages your agents as a managed service:\n\n```\nnpx mastra deploy\n```\n\nThe second is self-hosting. Add `@mastra/express`\n\nand mount the Mastra server into your existing Express app:\n\n```\nnpm install @mastra/express\npython\n// src/index.ts\nimport express from \"express\";\nimport { MastraServer } from \"@mastra/express\";\nimport { mastra } from \"./mastra\";\n\nconst app = express();\napp.use(express.json());\n\nconst server = new MastraServer({ app, mastra });\nawait server.init();\n\napp.listen(process.env.PORT ?? 3000);\n```\n\n`MastraServer.init()`\n\nregisters the agent endpoints under `/api`\n\n. 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`\n\n.\n\nMastra 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.\n\nThe 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.\n\nThe main thing I'd add to the stack described above: log `response.usage`\n\non every generate call so you can see what the agent actually costs per session in production. The number surprises most teams the first week.\n\nWhat does your agent stack look like right now? Raw SDK, a framework, something in-house? Curious what the pressure points are.\n\n**GDS K S** · [thegdsks.com](https://thegdsks.com) · follow on X [@thegdsks](https://x.com/thegdsks)\n\n*The orchestration layer is the part nobody talks about until they've rewritten it twice.*", "url": "https://wpnews.pro/news/building-a-production-ai-agent-in-typescript-with-mastra-a-2026-step-by-step", "canonical_source": "https://dev.to/thegdsks/building-a-production-ai-agent-in-typescript-with-mastra-a-2026-step-by-step-37dc", "published_at": "2026-07-13 23:30:15+00:00", "updated_at": "2026-07-13 23:44:04.211031+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "large-language-models", "artificial-intelligence"], "entities": ["Mastra", "Anthropic", "OpenAI", "Google", "LangChain", "LlamaIndex", "Vercel AI SDK"], "alternates": {"html": "https://wpnews.pro/news/building-a-production-ai-agent-in-typescript-with-mastra-a-2026-step-by-step", "markdown": "https://wpnews.pro/news/building-a-production-ai-agent-in-typescript-with-mastra-a-2026-step-by-step.md", "text": "https://wpnews.pro/news/building-a-production-ai-agent-in-typescript-with-mastra-a-2026-step-by-step.txt", "jsonld": "https://wpnews.pro/news/building-a-production-ai-agent-in-typescript-with-mastra-a-2026-step-by-step.jsonld"}}