Skip to content
Back to blog
Jan 18, 2025
8 min read
-- views

Learning Notes: Building a Chatbot with Mastra and Vercel AI SDK

Notes from building a simple AI chatbot while learning the Mastra framework and Vercel AI SDK.
Share

I’ve been learning about AI agent frameworks lately and decided to build a simple chatbot to understand how Mastra and the Vercel AI SDK work together. This post is mostly my notes on how the pieces connect.

Source code: GitHub | Project page

The Tech Stack

Before diving in, here’s what we’re working with:

PackagePurpose
@mastra/coreAgent and tool creation
@ai-sdk/reactuseChat hook for React
@ai-sdk/openaiOpenAI model provider
zodSchema validation for tools
nextReact framework with API routes
tailwindcssStyling

Architecture Overview

The app follows a straightforward flow:

┌─────────────────────────────────────────────────────────────────┐
│                         FRONTEND (Browser)                       │
│  page.tsx → ChatInterface.tsx → MessageBubble.tsx + ChatInput   │
│                    │                                             │
│              useChat() hook                                      │
│                    │                                             │
│                    │ POST /api/chat                              │
├────────────────────┼────────────────────────────────────────────┤
│                    ▼                                             │
│                          BACKEND (Server)                        │
│  route.ts → mastra.getAgent('fitnessCoach') → agent.generate()  │
│                                                      │           │
│                                                      ▼           │
│                                              OpenAI API Call     │
│                                              (gpt-4o-mini)       │
└─────────────────────────────────────────────────────────────────┘
  1. Frontend: React components with the useChat hook handle UI and state
  2. API Route: Next.js route receives messages and calls the Mastra agent
  3. Mastra Agent: Processes messages using OpenAI’s GPT-4o-mini model
  4. Tools: The agent can use tools (like a BMI calculator) for precise calculations
  5. Response: Streamed back to the frontend in AI SDK data stream format

Project Structure

fitness-coach/
├── src/
│   ├── mastra/
│   │   ├── agents/
│   │   │   ├── fitness-coach.ts     # Agent definition
│   │   │   └── index.ts
│   │   ├── tools/
│   │   │   ├── calculate-bmi.ts     # BMI calculator tool
│   │   │   └── index.ts
│   │   └── index.ts                 # Mastra instance
│   │
│   └── app/
│       ├── api/
│       │   └── chat/
│       │       └── route.ts         # API endpoint
│       ├── components/
│       │   ├── ChatInterface.tsx
│       │   ├── MessageBubble.tsx
│       │   └── ChatInput.tsx
│       ├── page.tsx
│       └── globals.css

├── .env.local                       # API keys
└── package.json

Building the Agent

What is an Agent?

An Agent is an AI entity with three key components:

  • Instructions: A system prompt defining personality and behavior
  • Model: The LLM that powers it (e.g., GPT-4o-mini)
  • Tools: Functions it can call for specific tasks

Defining the Fitness Coach

// src/mastra/agents/fitness-coach.ts
import { Agent } from "@mastra/core/agent"
import { calculateBmiTool } from "@/mastra/tools"

export const fitnessCoach = new Agent({
  name: "fitness coach",
  instructions: 'Role: You are "Apex," an elite AI Fitness Coach...',
  model: "openai/gpt-4o-mini",
  tools: {
    calculateBmiTool: calculateBmiTool,
  },
})

The instructions field is your system prompt—this is where you define the agent’s personality and behavior. The model field uses a “magic string” format (provider/model-name) that Mastra resolves automatically by reading OPENAI_API_KEY from your environment.

Creating Tools

Tools extend an agent’s capabilities beyond text generation. LLMs are notoriously bad at math, so offloading calculations to tools gives you precise results.

// src/mastra/tools/calculate-bmi.ts
import { createTool } from "@mastra/core/tools"
import { z } from "zod"

export const calculateBmiTool = createTool({
  id: "calculate-bmi",
  description: "Calculates BMI from height and weight",
  inputSchema: z.object({
    heightCm: z.number().describe("Height in centimeters"),
    weightKg: z.number().describe("Weight in kilograms"),
  }),
  outputSchema: z.object({
    bmi: z.number(),
    category: z.string(),
  }),
  execute: async ({ context }) => {
    const { heightCm, weightKg } = context
    const heightM = heightCm / 100
    const bmi = weightKg / (heightM * heightM)

    let category = ""
    if (bmi < 18.5) category = "Underweight"
    else if (bmi < 25) category = "Normal weight"
    else if (bmi < 30) category = "Overweight"
    else category = "Obese"

    return { bmi: Math.round(bmi * 10) / 10, category }
  },
})

The description field is crucial—the LLM reads this to decide when to use the tool. Zod schemas provide type-safe input/output validation.

The Mastra Instance

The Mastra instance acts as a central registry for all your agents:

// src/mastra/index.ts
import { Mastra } from "@mastra/core"
import { fitnessCoach } from "@/mastra/agents"

export const mastra = new Mastra({
  agents: {
    fitnessCoach: fitnessCoach,
  },
})

Agents are retrieved via mastra.getAgent('fitnessCoach'). This instance can also hold workflows, memory, and storage configurations as your app grows.

The API Route

The API route bridges the frontend and the Mastra agent:

// src/app/api/chat/route.ts
import { mastra } from "@/mastra"

export async function POST(req: Request) {
  const { messages } = await req.json()

  const agent = mastra.getAgent("fitnessCoach")
  const result = await agent.generate(messages)
  const text = result.text

  // Format as AI SDK data stream
  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue(encoder.encode(`0:${JSON.stringify(text)}\n`))
      controller.enqueue(
        encoder.encode(
          `e:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`
        )
      )
      controller.close()
    },
  })

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  })
}

Understanding the Data Stream Protocol

The frontend useChat hook expects responses in a specific format:

PrefixMeaning
0:Text content
e:Finish event with metadata
d:Data event

This looks like:

0:"Hello, I'm Apex!"\n
0:" How can I help?"\n
e:{"finishReason":"stop"}

Frontend Components

The Chat Interface

The useChat hook from @ai-sdk/react handles all the state management:

// src/app/components/ChatInterface.tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';

export function ChatInterface() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
    useChat({ api: '/api/chat' });

  return (
    <div className="flex flex-col h-screen">
      <header>Apex Fitness Coach</header>

      <main>
        {messages.map((msg) => (
          <MessageBubble key={msg.id} role={msg.role} content={msg.content} />
        ))}
      </main>

      <footer>
        <ChatInput
          input={input}
          handleInputChange={handleInputChange}
          handleSubmit={handleSubmit}
          isLoading={isLoading}
        />
      </footer>
    </div>
  );
}

The hook returns everything you need:

PropertyTypeDescription
messagesarrayChat message history
inputstringCurrent input value
handleInputChangefunctionInput change handler
handleSubmitfunctionForm submit handler
isLoadingbooleanLoading state
errorErrorError object if failed
reloadfunctionRetry last message
stopfunctionStop generation

Message Bubble

// src/app/components/MessageBubble.tsx
interface MessageBubbleProps {
  role: string;
  content: string;
}

export function MessageBubble({ role, content }: MessageBubbleProps) {
  const isUser = role === 'user';

  return (
    <div className={isUser ? 'justify-end' : 'justify-start'}>
      <div className={isUser ? 'bg-emerald-600 text-white' : 'bg-white'}>
        {content}
      </div>
    </div>
  );
}

Chat Input

// src/app/components/ChatInput.tsx
interface ChatInputProps {
  input: string;
  handleInputChange: (e: ChangeEvent<HTMLInputElement>) => void;
  handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
  isLoading: boolean;
}

export function ChatInput({
  input,
  handleInputChange,
  handleSubmit,
  isLoading
}: ChatInputProps) {
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={input}
        onChange={handleInputChange}
        disabled={isLoading}
      />
      <button type="submit" disabled={isLoading}>
        Send
      </button>
    </form>
  );
}

How the Data Flows

Here’s the complete journey of a message:

1. User types message       → ChatInput.tsx (input state)

2. Form submit              → handleSubmit() from useChat

3. POST request             → /api/chat with { messages: [...] }

4. Server receives          → route.ts extracts messages

5. Agent processes          → agent.generate(messages)

6. Tool execution           → If needed, BMI tool runs

7. OpenAI API call          → gpt-4o-mini with system prompt

8. Response returns         → Formatted as data stream

9. Frontend receives        → useChat hook parses stream

10. UI updates              → MessageBubble renders new message

How Re-rendering Works

The useChat hook uses useState internally. When the API response arrives, it calls setMessages, which triggers a React re-render:

API response arrives


useChat parses the response


useChat calls setMessages([...prev, newMessage])


React re-renders ChatInterface


messages.map() outputs new MessageBubble

Here’s a simplified look at what useChat does under the hood:

function useChat({ api }) {
  const [messages, setMessages] = useState([])
  const [input, setInput] = useState("")
  const [isLoading, setIsLoading] = useState(false)

  async function handleSubmit(e) {
    e.preventDefault()

    // Add user message
    const userMsg = { id: crypto.randomUUID(), role: "user", content: input }
    setMessages((prev) => [...prev, userMsg]) // triggers re-render
    setInput("")
    setIsLoading(true)

    // Call API
    const res = await fetch(api, {
      method: "POST",
      body: JSON.stringify({ messages: [...messages, userMsg] }),
    })

    // Parse and add assistant message
    const text = await res.text()
    const content = parseDataStream(text)
    const assistantMsg = { id: crypto.randomUUID(), role: "assistant", content }
    setMessages((prev) => [...prev, assistantMsg]) // triggers re-render
    setIsLoading(false)
  }

  return { messages, input, handleInputChange, handleSubmit, isLoading }
}

SDK Responsibilities

This project uses both Mastra and Vercel AI SDK. Here’s how they divide the work:

LayerSDKPurpose
FrontendVercel AI SDK (@ai-sdk/react)useChat manages state, sends requests, parses stream
BackendMastra (@mastra/core)Agent definition, tool execution, LLM calls
ProtocolAI SDK Data Stream0: and e: prefixes that useChat expects

Alternatives to the Vercel AI SDK

Mastra can work without @ai-sdk/react. Here are two alternatives:

Mastra’s Built-in Playground

Mastra includes a dev server with a chat UI:

bun run mastra:dev

This launches a playground at http://localhost:4111 where you can chat with your agent directly—no custom frontend needed.

Plain React

You can replace useChat with regular React state, though you’ll need to handle the data stream parsing yourself:

'use client';
import { useState } from 'react';

export function ChatInterface() {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    if (!input.trim()) return;

    const userMessage = { role: 'user', content: input };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setIsLoading(true);

    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages: [...messages, userMessage] }),
    });

    const text = await res.text();
    const content = JSON.parse(text.split('\n')[0].slice(2));

    setMessages(prev => [...prev, { role: 'assistant', content }]);
    setIsLoading(false);
  }

  return (/* same JSX */);
}
ApproachProsCons
Mastra PlaygroundZero frontend codeLess customizable UI
Plain ReactNo extra dependenciesMore boilerplate, manual stream parsing
useChatClean API, auto state managementExtra dependency

The useChat hook saves roughly 30 lines of boilerplate.

Environment Setup

Create a .env.local file:

OPENAI_API_KEY=sk-proj-your-key-here

Mastra automatically reads this when you use the magic string format (openai/gpt-4o-mini).

Wrapping Up

That’s the basic structure. It’s a simple project, but it helped me understand how Mastra agents work and how they integrate with the Vercel AI SDK on the frontend. There’s still a lot I haven’t explored yet—streaming responses, conversation memory, adding more tools—but this was a good starting point for learning the fundamentals.