Mastra: Build Powerful AI Agents with Typescript

Last updated: August 13 2025

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 conditions
  • src/mastra/agents/weather-agent.ts: an agent wired to OpenAI, with memory
  • src/mastra/workflows/weather-workflow.ts: a 2‑step workflow that plans activities using the agent
  • src/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:

env
OPENAI_API_KEY=your-key-here

2) Available scripts:

bash
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.

typescript
// 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}&current=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.

typescript
// 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.

typescript
// 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.

typescript
// 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}&current=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.

typescript
// 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:

bash
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.

Mastra product screenshot

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.