DEV Community

meijin
meijin

Posted on • Originally published at zenn.dev

Building a Teacher Search AI Agent with Mastra and Structured Data Streaming

Recently, we released a "teacher search agent" feature at Manalink, an online tutoring platform. This AI agent helps users find the perfect teacher through natural language conversations.

Here's a quick demo of the feature:
Demo GIF

Note: Screen shown is from development phase / Search results are examples

Key Features

The AI teacher search agent includes these distinctive capabilities:

  • Character-by-character streaming responses similar to ChatGPT, creating a familiar UX
  • Natural language input processing - users can describe their needs in plain language, and the AI understands subjects, grade levels, and other criteria
  • Unstructured data search - beyond typical filters like subjects and grades, users can search using criteria like "needs help with communication skills" or "prefers strict teaching style"
  • Rich structured UI components - instead of plain text responses, the system displays structured cards with teacher rankings, recommendations, and detailed information through JSON streaming
  • Real-time progress indicators - users see live updates as the AI searches through various data sources, reducing perceived wait time
  • Interactive conversations - users can continue refining their search based on initial results

Technical Architecture Overview

To achieve these features, we implemented several key technical decisions:

  • AI Agent approach instead of simple LLM API calls for flexible, context-aware responses
  • Mastra framework as our AI agent foundation, leveraging its streaming capabilities and existing tooling
  • Next.js and React Native integration with proper AWS infrastructure configuration for streaming
  • Embedding and RAG for similarity search to handle unstructured data without consuming excessive context
  • Careful tool, schema, and instruction design to ensure stable agent behavior
  • Custom useStructuredChat hook extending Vercel AI SDK's useChat for cross-platform structured data streaming
  • Business-focused accuracy metrics ensuring the agent meets real-world educational matching requirements

This article focuses primarily on implementing structured data streaming for AI agents while touching on UX considerations and the broader challenges of agent development.

Environment Information

This implementation uses:

  • June 2025 timeframe
  • Next.js 14.x
  • @mastra/core and related Mastra packages 0.10.x
  • Expo 52.x

The Challenge of Structured Data Streaming

Why Streaming Matters

Before diving into technical implementation, let's establish why streaming is essential and why it needs to be structured.

User Experience Benefits:
AI generation, despite optimizations, takes time. For tasks users perceive as "doable myself" or "available elsewhere," even 10 seconds feels excessive. Streaming display eliminates the "waiting" sensation by showing progressive results.

Changed User Expectations:
Major AI services (ChatGPT, Claude, Gemini) have made streaming standard. Like how LINE popularized chat UI as a communication standard, users now expect AI responses to stream. This creates a baseline expectation developers must meet.

Why Structure Matters

Rich UI and Interactions:
Structured data (JSON) enables direct mapping to React components. For example:

  • Plain text: Restaurant name, description, URL as text
  • Structured data: Restaurant name, rating, image URL, reservation link, availability button - enabling rich restaurant cards with actionable elements

Application Integration:
Structured output enables sophisticated workflows. JSON responses can trigger additional data fetching via SWR, enable conditional UI rendering, and support complex application logic that plain text cannot facilitate.

Implementation Approaches

Option 1: Basic useChat

Mastra internally uses Vercel AI SDK, making the useChat hook directly available. However, this approach outputs plain text only. Even with JSON format prompts, streaming creates incomplete JSON fragments like { "name": "Jo that cannot be parsed until completion, defeating streaming benefits.

During development, I experimented with custom parsing rules where {{teacherId:123456789}} in AI output would render as <TeacherCard teacherId={123456789} /> components. While functional, this approach can cause visual artifacts and imposes external constraints.

Option 2: useObject Hook

The useObject hook represents the primary structured streaming solution for non-agent scenarios:

import { mastra } from "@/src/mastra";

export async function POST(req: Request) {
  const body = await req.json();
  const myAgent = mastra.getAgent("weatherAgent");
  const stream = await myAgent.stream(body, {
    output: z.object({
      weather: z.string(),
    }),
  });

  return stream.toTextStreamResponse();
}
Enter fullscreen mode Exit fullscreen mode
import { experimental_useObject as useObject } from '@ai-sdk/react';

export default function Page() {
  const { object, submit } = useObject({
    api: '/api/use-object',
    schema: z.object({
      weather: z.string(),
    }),
  });

  return (
    <div>
      <button onClick={() => submit('example input')}>Generate</button>
      {object?.weather && <p>{object.weather}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

However, with agent calls, intermediate tool invocations don't match the schema, preventing users from seeing progress updates—only final results appear.

Option 3: Custom Hook Development

After investigating various approaches and reviewing GitHub discussions, I developed a custom solution using Vercel AI SDK's parsePartialJson utility function—the same function powering useObject internally.

Here's the core implementation:

parts: message.parts.map((part) => {
  if (part.type === 'text') {
    const parsedMessage = parsePartialJson(part.text);
    if (['repaired-parse', 'successful-parse'].includes(parsedMessage.state)) {
      return {
        type: 'output',
        structuredData: parsedMessage.value as PartialSchema,
      };
    }
Enter fullscreen mode Exit fullscreen mode

The solution involves parsing partial JSON at each streaming step. The parsePartialJson function intelligently repairs incomplete JSON:

  • { "name": "Jo becomes { "name": "Jo" }
  • { "array": [1, 2, becomes { "array": [1, 2] }
  • { "nested": { "foo": "bar becomes { "nested": { "foo": "bar" } }

This approach requires handling DeepPartial<z.infer<TSchema>> types on the frontend, with appropriate undefined checks.

For tool invocation progress display, use experimental_output instead of output in agent calls:

const result = await mastra.getAgent('manalinkAgent').stream(messages, {
  experimental_output: ResponseSchema,
});
return result.toDataStreamResponse();
Enter fullscreen mode Exit fullscreen mode

Frontend Implementation

UI rendering requires careful handling of partial data:

{messages.map((message) => (
  <div key={message.id}>
    <div>{message.role === 'user' ? 'User' : 'AI'}</div>
    {message.parts.map((part, i) => {
      switch (part.type) {
        case 'output':
          const value = part.structuredData;
          if (!value?.teachers) return null;

          return (
            <div>
              {value.teachers
                .filter((teacher) => teacher?.id)
                .map((teacher) => (
                  <div key={teacher.id}>
                    <h3>
                      {teacher.ranking && <span>{teacher.ranking}</span>}
                      <span>{teacher.name}</span>
                    </h3>
                    {teacher.ranking && (
                      <p>Reason: {teacher.rankReason}</p>
                    )}
                    <p>{teacher.recommendation}</p>
                    <TeacherCard
                      id={teacher.id}
                      subjectIds={teacher.subjectIds}
                      gradeId={teacher.gradeId}
                    />
                  </div>
                ))}
            </div>
          );

        case 'tool-invocation':
          return (
            <p key={`${message.id}-${i}`}>
              {getToolDisplayName(part.toolInvocation.toolName)}
              {loading && <span>...</span>}
            </p>
          );

        default:
          return null;
      }
    })}
  </div>
))}
Enter fullscreen mode Exit fullscreen mode

Technical Challenges and Solutions

Application-Level Barriers

Next.js App Router Requirement:
Integrating Mastra requires App Router API Routes. While our main app uses Pages Router, we can call App Router APIs from Pages Router components.

Expo 52 Fetch Requirement:
React Native streaming requires Expo 52's enhanced fetch API for proper streaming support.

Infrastructure Considerations

Local Development Setup:
Our Nginx-fronted local environment required specific configuration:

location /api/ {
    proxy_pass {Next.js host and port};
    proxy_buffering off; # Critical for streaming
}
Enter fullscreen mode Exit fullscreen mode

Production Deployment:
Network configuration becomes complex in staging/production environments. Early deployment testing is crucial for streaming and AI agent implementations.

AI Agent Tool Design Philosophy

Structured vs. Unstructured Data Search

We distinguish between two search types:

  • Structured data: Subject preferences, grade levels—existing database fields that can be filtered programmatically
  • Unstructured data: "Communication difficulties," "strict teaching style"—requiring embedding and vector search

Balancing Mechanical and AI-Driven Processing

Rather than providing many primitive tools to the agent, we implemented fewer tools that combine mechanical filtering with vector search. This approach prevents hallucinations in areas requiring precise filtering while leveraging AI strengths for similarity matching and recommendation generation.

Our Tool Strategy:

  • Mechanical filtering: Handle programmatically to ensure accuracy
  • AI processing: Query generation, similarity scoring, recommendation writing, result ranking

Agile Tool Development

Tool design requires iterative development:

  1. Build basic tools quickly
  2. Integrate with agent early
  3. Observe agent behavior
  4. Refine based on results

Avoid over-engineering individual tools—focus on overall agent effectiveness.

Performance and Monitoring

Speed Optimization

Tool input schema length significantly impacts generation time. Keep tool inputs concise and use memory management to avoid verbose tool calls.

For RAG implementation, we balanced search accuracy with speed, prioritizing reasonable response times over perfect precision given the inherent complexity of teacher-student matching.

Logging and Observability

Mastra provides Pino-based JSON logging for CloudWatch integration:

export const mastraLogger = new PinoLogger({
  name: 'Mastra',
  level: process.env.NODE_ENV === 'production' ? 'info' : 
         (process.env.AI_AGENT_LOG_LEVEL as LogLevel | undefined) ?? 'info',
  overrideDefaultTransports: true,
});
Enter fullscreen mode Exit fullscreen mode

Project Management and Timeline

Resource Allocation

Total effort: 2 person-months

  • Web Engineer: Vector data processing, embedding research, RAG prototyping
  • Myself: Everything else (streaming implementation, agent design, UI integration)

Risk Management Strategy

Key approach: Maintain non-agent alternatives until final commitment

Since our team lacked agent development experience, I ensured fallback options remained viable throughout development. This allowed critical evaluation of agent necessity while building stakeholder confidence through progressive demonstrations.

Team Education and Buy-in

Technical Education:

  • Internal workshops on agent concepts
  • Hands-on MCP server development
  • Cursor/coding agent analysis sessions

Stakeholder Engagement:

  • Visual demonstrations of agent capabilities
  • Canary releases for internal testing
  • Progressive feature reveals showing agent advantages

Results and Learnings

Benefits of Structured Streaming

  • Rich UI variety: Different data types enable tailored visual designs
  • Contextual messaging: Conditional messages (e.g., no-results explanations) with custom styling
  • Clean schema separation: Agent prompts focus on business logic while schemas handle data structure

Key Takeaways

  1. Agent development feels like extending familiar coding assistants to new domains
  2. Structured streaming significantly enhances user experience beyond plain text responses
  3. Iterative tool development proves more effective than upfront perfect design
  4. Cross-platform considerations (Next.js + React Native) require careful infrastructure planning
  5. Team education and progressive demonstration essential for stakeholder buy-in

Looking Forward

AI agents represent a natural evolution from the coding assistants we use daily. The combination of streaming responses, structured data, and thoughtful tool design creates genuinely useful applications that enhance rather than replace human decision-making.

For developers interested in agent development, start with your daily coding assistant—understand its tool usage patterns, experiment with MCP servers, and gradually build familiarity with agent behavior patterns.


Interested in AI agent development discussions?
Connect with me on X or Pitta - I'd love to hear about your agent projects and challenges!

Top comments (1)

Collapse
 
tushar_singhmanhas_46f48 profile image
Tushar Singh Manhas

Looks quite interesting

Some comments may only be visible to logged-in visitors. Sign in to view all comments.