Mastra: Build Powerful AI Agents with Typescript
This tutorial builds on the scaffolded weather example created by npx create-mastra
, and expands it to introduce key Mastra concepts while reusing and extending your existing files:
- Agents with tools and memory
- Tools with typed inputs/outputs via
zod
- Workflows with steps and agent-calls
- Durable local storage
By the end, you’ll have a weather assistant that can fetch current conditions, plan activities via a workflow, and convert temperature units on demand.
What you already have
src/mastra/tools/weather-tool.ts
: a tool that calls Open‑Meteo to get current conditionssrc/mastra/agents/weather-agent.ts
: an agent wired to OpenAI, with memorysrc/mastra/workflows/weather-workflow.ts
: a 2‑step workflow that plans activities using the agentsrc/mastra/index.ts
: Mastra instance registering the agent and workflow
We will enhance this setup with a new tool for unit conversion and small improvements for durability and local execution.
Prerequisites
1) Add your OpenAI key in .env
:
OPENAI_API_KEY=your-key-here
2) Available scripts:
npm run dev npm run build npm run start
Core concepts in this project
- Agents: AI reasoning engines that can call tools and use memory
- Tools: Typed functions exposed to agents/workflows
- Workflows: Steps with schemas that orchestrate tools and agents
- Memory: Backed by
@mastra/libsql
to persist conversation context
1) The Weather Tool
This tool fetches the current weather using Open‑Meteo and returns a typed payload.
// src/mastra/tools/weather-tool.ts import { createTool } from '@mastra/core/tools'; import { z } from 'zod'; interface GeocodingResponse { results: { latitude: number; longitude: number; name: string; }[]; } interface WeatherResponse { current: { time: string; temperature_2m: number; apparent_temperature: number; relative_humidity_2m: number; wind_speed_10m: number; wind_gusts_10m: number; weather_code: number; }; } export const weatherTool = createTool({ id: 'get-weather', description: 'Get current weather for a location', inputSchema: z.object({ location: z.string().describe('City name'), }), outputSchema: z.object({ temperature: z.number(), feelsLike: z.number(), humidity: z.number(), windSpeed: z.number(), windGust: z.number(), conditions: z.string(), location: z.string(), }), execute: async ({ context }) => { return await getWeather(context.location); }, }); const getWeather = async (location: string) => { const geocodingUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`; const geocodingResponse = await fetch(geocodingUrl); const geocodingData = (await geocodingResponse.json()) as GeocodingResponse; if (!geocodingData.results?.[0]) { throw new Error(`Location '${location}' not found`); } const { latitude, longitude, name } = geocodingData.results[0]; const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_gusts_10m,weather_code`; const response = await fetch(weatherUrl); const data = (await response.json()) as WeatherResponse; return { temperature: data.current.temperature_2m, feelsLike: data.current.apparent_temperature, humidity: data.current.relative_humidity_2m, windSpeed: data.current.wind_speed_10m, windGust: data.current.wind_gusts_10m, conditions: getWeatherCondition(data.current.weather_code), location: name, }; }; function getWeatherCondition(code: number): string { const conditions: Record<number, string> = { 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', 45: 'Foggy', 48: 'Depositing rime fog', 51: 'Light drizzle', 53: 'Moderate drizzle', 55: 'Dense drizzle', 56: 'Light freezing drizzle', 57: 'Dense freezing drizzle', 61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain', 66: 'Light freezing rain', 67: 'Heavy freezing rain', 71: 'Slight snow fall', 73: 'Moderate snow fall', 75: 'Heavy snow fall', 77: 'Snow grains', 80: 'Slight rain showers', 81: 'Moderate rain showers', 82: 'Violent rain showers', 85: 'Slight snow showers', 86: 'Heavy snow showers', 95: 'Thunderstorm', 96: 'Thunderstorm with slight hail', 99: 'Thunderstorm with heavy hail', }; return conditions[code] || 'Unknown'; }
2) New: Unit Conversion Tool
We’ll add a temperature conversion tool that the agent can call when the user requests results in a different unit.
// src/mastra/tools/unit-conversion-tool.ts import { createTool } from '@mastra/core/tools'; import { z } from 'zod'; type TemperatureUnit = 'C' | 'F' | 'K'; function convertTemperature(value: number, from: TemperatureUnit, to: TemperatureUnit): number { if (from === to) return value; // Normalize to Kelvin let kelvin: number; switch (from) { case 'C': kelvin = value + 273.15; break; case 'F': kelvin = (value - 32) * (5 / 9) + 273.15; break; case 'K': kelvin = value; break; } // Convert from Kelvin to target switch (to) { case 'C': return +(kelvin - 273.15).toFixed(2); case 'F': return +(((kelvin - 273.15) * 9) / 5 + 32).toFixed(2); case 'K': return +kelvin.toFixed(2); } } export const unitConversionTool = createTool({ id: 'convert-temperature', description: 'Convert temperature values between Celsius, Fahrenheit, and Kelvin', inputSchema: z.object({ value: z.number().describe('Numeric temperature value to convert'), fromUnit: z.enum(['C', 'F', 'K']).describe('Source unit'), toUnit: z.enum(['C', 'F', 'K']).describe('Target unit'), }), outputSchema: z.object({ value: z.number().describe('Converted value rounded to 2 decimals'), unit: z.enum(['C', 'F', 'K']).describe('Unit of the converted value'), }), execute: async ({ context }) => { const result = convertTemperature(context.value, context.fromUnit as TemperatureUnit, context.toUnit as TemperatureUnit); return { value: result, unit: context.toUnit }; }, }); export { convertTemperature };
3) Agent with Memory and Multiple Tools
We extend the agent to advertise the conversion capability and keep durable memory via LibSQL.
// src/mastra/agents/weather-agent.ts import { openai } from '@ai-sdk/openai'; import { Agent } from '@mastra/core/agent'; import { Memory } from '@mastra/memory'; import { LibSQLStore } from '@mastra/libsql'; import { weatherTool } from '../tools/weather-tool'; import { unitConversionTool } from '../tools/unit-conversion-tool'; export const weatherAgent = new Agent({ name: 'Weather Agent', instructions: ` You are a helpful weather assistant that provides accurate weather information and can help planning activities based on the weather. Your primary function is to help users get weather details for specific locations. When responding: - Always ask for a location if none is provided - If the location name isn't in English, please translate it - If giving a location with multiple parts (e.g. "New York, NY"), use the most relevant part (e.g. "New York") - Include relevant details like humidity, wind conditions, and precipitation - Keep responses concise but informative - If the user asks for activities and provides the weather forecast, suggest activities based on the weather forecast. - If the user asks for activities, respond in the format they request. - If the user asks for different temperature units, use the convert-temperature tool. Use the weatherTool to fetch current weather data. `, model: openai('gpt-4o-mini'), tools: { weatherTool, unitConversionTool }, memory: new Memory({ storage: new LibSQLStore({ url: 'file:../mastra.db', // path is relative to the .mastra/output directory }), }), });
Tip: the memory store persists under .mastra/output
and is shared by tools, agents, and workflows.
4) Workflow: Fetch Weather → Plan Activities
The workflow already fetches weather, then asks the agent to plan activities with structured guidance.
// src/mastra/workflows/weather-workflow.ts import { createStep, createWorkflow } from '@mastra/core/workflows'; import { z } from 'zod'; import { weatherAgent } from '../agents/weather-agent'; const forecastSchema = z.object({ date: z.string(), maxTemp: z.number(), minTemp: z.number(), precipitationChance: z.number(), condition: z.string(), location: z.string(), }); function getWeatherCondition(code: number): string { const conditions: Record<number, string> = { 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', 45: 'Foggy', 48: 'Depositing rime fog', 51: 'Light drizzle', 53: 'Moderate drizzle', 55: 'Dense drizzle', 61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain', 71: 'Slight snow fall', 73: 'Moderate snow fall', 75: 'Heavy snow fall', 95: 'Thunderstorm', }; return conditions[code] || 'Unknown'; } const fetchWeather = createStep({ id: 'fetch-weather', description: 'Fetches weather forecast for a given city', inputSchema: z.object({ city: z.string().describe('The city to get the weather for'), }), outputSchema: forecastSchema, execute: async ({ inputData }) => { if (!inputData) { throw new Error('Input data not found'); } const geocodingUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(inputData.city)}&count=1`; const geocodingResponse = await fetch(geocodingUrl); const geocodingData = (await geocodingResponse.json()) as { results: { latitude: number; longitude: number; name: string }[]; }; if (!geocodingData.results?.[0]) { throw new Error(`Location '${inputData.city}' not found`); } const { latitude, longitude, name } = geocodingData.results[0]; const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=precipitation,weathercode&timezone=auto,&hourly=precipitation_probability,temperature_2m`; const response = await fetch(weatherUrl); const data = (await response.json()) as { current: { time: string; precipitation: number; weathercode: number; }; hourly: { precipitation_probability: number[]; temperature_2m: number[]; }; }; const forecast = { date: new Date().toISOString(), maxTemp: Math.max(...data.hourly.temperature_2m), minTemp: Math.min(...data.hourly.temperature_2m), condition: getWeatherCondition(data.current.weathercode), precipitationChance: data.hourly.precipitation_probability.reduce( (acc, curr) => Math.max(acc, curr), 0, ), location: name, }; return forecast; }, }); const planActivities = createStep({ id: 'plan-activities', description: 'Suggests activities based on weather conditions', inputSchema: forecastSchema, outputSchema: z.object({ activities: z.string(), }), execute: async ({ inputData, mastra }) => { const forecast = inputData; if (!forecast) { throw new Error('Forecast data not found'); } // Prefer getting the agent from the runtime, fallback to direct import for local execution const agent = mastra?.getAgent('weatherAgent') ?? weatherAgent; if (!agent) { throw new Error('Weather agent not found'); } const prompt = `Based on the following weather forecast for ${forecast.location}, suggest appropriate activities: ${JSON.stringify(forecast, null, 2)} For each day in the forecast, structure your response exactly as follows: 📅 [Day, Month Date, Year] ═══════════════════════════ 🌡️ WEATHER SUMMARY • Conditions: [brief description] • Temperature: [X°C/Y°F to A°C/B°F] • Precipitation: [X% chance] 🌅 MORNING ACTIVITIES Outdoor: • [Activity Name] - [Brief description including specific location/route] Best timing: [specific time range] Note: [relevant weather consideration] 🌞 AFTERNOON ACTIVITIES Outdoor: • [Activity Name] - [Brief description including specific location/route] Best timing: [specific time range] Note: [relevant weather consideration] 🏠 INDOOR ALTERNATIVES • [Activity Name] - [Brief description including specific venue] Ideal for: [weather condition that would trigger this alternative] ⚠️ SPECIAL CONSIDERATIONS • [Any relevant weather warnings, UV index, wind conditions, etc.] Guidelines: - Suggest 2-3 time-specific outdoor activities per day - Include 1-2 indoor backup options - For precipitation >50%, lead with indoor activities - All activities must be specific to the location - Include specific venues, trails, or locations - Consider activity intensity based on temperature - Keep descriptions concise but informative Maintain this exact formatting for consistency, using the emoji and section headers as shown.`; const response = await agent.stream([ { role: 'user', content: prompt, }, ]); let activitiesText = ''; for await (const chunk of response.textStream) { process.stdout.write(chunk); activitiesText += chunk; } return { activities: activitiesText, }; }, }); const weatherWorkflow = createWorkflow({ id: 'weather-workflow', inputSchema: z.object({ city: z.string().describe('The city to get the weather for'), }), outputSchema: z.object({ activities: z.string(), }), }) .then(fetchWeather) .then(planActivities); weatherWorkflow.commit(); export { weatherWorkflow };
5) Mastra Instance with Durable Storage
Switch storage to a file‑backed LibSQL DB so your evals/telemetry/memory persist across runs.
// src/mastra/index.ts import { Mastra } from '@mastra/core/mastra'; import { PinoLogger } from '@mastra/loggers'; import { LibSQLStore } from '@mastra/libsql'; import { weatherWorkflow } from './workflows/weather-workflow'; import { weatherAgent } from './agents/weather-agent'; export const mastra = new Mastra({ workflows: { weatherWorkflow }, agents: { weatherAgent }, storage: new LibSQLStore({ // Persist to local sqlite db under .mastra/output url: 'file:../mastra.db', }), logger: new PinoLogger({ name: 'Mastra', level: 'info', }), });
6) Try it out
Start the dev server:
npm run dev
Example prompts to try with the agent panel:
- “What’s the weather in Tokyo?”
- “Convert the temperature to fahrenheit”
- “Plan indoor and outdoor activities for today based on the weather conditions”
You should see a structured activities plan streamed from the agent.

Where to go next
- Add a forecast tool and branch your workflow on precipitation chance
- Log custom telemetry with
@mastra/loggers
- Add RAG for location‑specific venues
- Write evals to ensure responses follow your format
This repo now demonstrates agents, tools, workflows, and memory with a small but realistic example you can grow from.