Small e‑commerce owners lose hours per day manually checking WMS, carrier portals, and return logs for every customer call. Agents frequently restart the conversation from scratch, frustrating buyers and delaying resolutions.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This recipe builds a multi-agent chat system that routes customer e-commerce inquiries to specialized agents (order lookup, shipping, and returns) without losing conversation context. When a customer asks about an order, then pivots to tracking a package, the shipping agent picks up the thread without making the customer repeat themselves. The system uses Mastra agents connected to OpenAI, with context compressed and handed off between agents via the REAA handoff stack. Sessions are persisted in Firestore so customers can resume where they left off.
Prerequisites
Node.js 22 or later
pnpm 10 (run corepack enable then corepack prepare pnpm@10 --activate if you do not have it)
@ai-sdk/openai — OpenAI provider for Mastra models
stripe — order lookup via payment intents
shipengine — shipment tracking
langfuse — observability tracing
zod/v4 — request/response schema validation
Step 2: Configure environment variables
The .env.example file in the project root has the entries you need. Copy it to .env.local and fill in your real values.
terminal
cp .env.example .env.local
Your .env.local should look like this once filled in:
env
NODE_ENV=development# OpenAI — get a key at https://platform.openai.com/api-keysOPENAI_API_KEY=sk-proj-...# Stripe — get keys at https://dashboard.stripe.com/apikeysSTRIPE_SECRET_KEY=sk_live_...# ShipEngine — get a key at https://dashboard.shipengine.com/api-keysSHIPENGINE_API_KEY=TEST_...# Langfuse — create project at https://cloud.langfuse.comLANGFUSE_PUBLIC_KEY=pk_live_...LANGFUSE_SECRET_KEY=sk_live_...LANGFUSE_HOST=https://cloud.langfuse.com# Google Cloud — project for Firestore session persistenceGOOGLE_CLOUD_PROJECT=your-gcp-project-idFIRESTORE_DATABASE=(default)# Agent runtimeLOG_LEVEL=info
The src/lib/env.ts module reads these variables at startup and validates that the four required keys (OPENAI_API_KEY, STRIPE_SECRET_KEY, SHIPENGINE_API_KEY, GOOGLE_CLOUD_PROJECT) are present before the app starts.
Step 3: Set up type-safe schemas
The recipe validates all wire-format data with Zod. Open src/lib/schemas.ts.
The file re-exports the session-related schemas from @reaatech/agent-mesh and defines custom schemas for orders, shipments, returns, and the chat request/response wire format. The ChatRequestBodySchema is what the API route uses to validate incoming POST bodies.
Step 4: Define the agent capabilities registry
The registry declares the three specialist agents and their capabilities. Open src/agents/registry.ts.
AgentRegistry from @reaatech/agent-handoff-routing holds the declared capabilities. The capability-based router later queries this registry to decide which specialist should handle a handoff.
Step 5: Create the three specialist agents
Create src/agents/order-lookup.ts. This agent handles order status and history lookups.
ts
import { Agent } from "@mastra/core/agent";import { createTool } from "@mastra/core/tools";import { openai } from "@ai-sdk/openai";import { z } from "zod/v4";export async function getOrderExecute(args: { orderId: string }): Promise<Record<string, unknown>> { return { orderId: args.orderId, status: "processing", items: ["Item 1", "Item 2"], total: 99.99, createdAt: new Date().toISOString(), };}export async function getOrderHistoryExecute(args: { userId: string; limit?: number }): Promise<Record<string, unknown>> { const actualLimit = args.limit ?? 10; const orders = []; for (let i = 0; i < actualLimit; i++) { orders.push({ orderId: `ORD-${String(i + 1)}`, status: "delivered", items: [`Product ${String(i + 1)}`], total: 49.99 + i * 10, createdAt: new Date(Date.now() - i * 86400000).toISOString(), }); } return { userId: args.userId, orders };}const getOrderTool = createTool({ id: "getOrder", description: "Retrieve order details by order ID", inputSchema: z.object({ orderId: z.string().min(1) }), execute: getOrderExecute,});const getOrderHistoryTool = createTool({ id: "getOrderHistory", description: "Retrieve order history for a user", inputSchema: z.object({ userId: z.string().min(1), limit: z.number().optional().default(10), }), execute: getOrderHistoryExecute,});export const orderLookupAgent = new Agent({ id: "order-lookup", name: "Order Lookup Specialist", instructions: "You are an order lookup specialist for an e-commerce platform. " + "Your job is to help customers find information about their orders and order history. " + "Use the getOrder tool to look up a specific order by ID. " + "Use the getOrderHistory tool to retrieve a customer's recent orders. " + "Be helpful, concise, and accurate in your responses.", model: openai("gpt-4o-mini"), tools: { getOrder: getOrderTool, getOrderHistory: getOrderHistoryTool },});
Create src/agents/shipping.ts.
ts
import { Agent } from "@mastra/core/agent";import { createTool } from "@mastra/core/tools";import { openai } from "@ai-sdk/openai";import { z } from "zod/v4";export async function trackShipmentExecute(args: { trackingNumber: string; carrier?: string }): Promise<Record<string, unknown>> { return { trackingNumber: args.trackingNumber, carrier: args.carrier || "UPS", status: "in_transit", events: [ { timestamp: new Date().toISOString(), description: "Package in transit", location: "Distribution Center", }, ], };}export async function reportIssueExecute(args: { trackingNumber: string; issue: string }): Promise<Record<string, unknown>> { return { ticketId: `TKT-${String(Date.now())}`, trackingNumber: args.trackingNumber, issue: args.issue, status: "reported", createdAt: new Date().toISOString(), };}const trackShipmentTool = createTool({ id: "trackShipment", description: "Track a shipment by tracking number", inputSchema: z.object({ trackingNumber: z.string().min(1), carrier: z.string().optional(), }), execute: trackShipmentExecute,});const reportIssueTool = createTool({ id: "reportIssue", description: "Report a shipping issue for a tracking number", inputSchema: z.object({ trackingNumber: z.string().min(1), issue: z.string().min(1), }), execute: reportIssueExecute,});export const shippingAgent = new Agent({ id: "shipping", name: "Shipping Specialist", instructions: "You are a shipping specialist for an e-commerce platform. " + "Your job is to help customers track their shipments and report shipping issues. " + "Use the trackShipment tool to check the status of a shipment. " + "Use the reportIssue tool to report a problem with a shipment. " + "Be helpful, concise, and accurate in your responses.", model: openai("gpt-4o-mini"), tools: { trackShipment: trackShipmentTool, reportIssue: reportIssueTool },});
Create src/agents/returns.ts.
ts
import { Agent } from "@mastra/core/agent";import { createTool } from "@mastra/core/tools";import { openai } from "@ai-sdk/openai";import { z } from "zod/v4";export async function initiateReturnExecute(args: { orderId: string; reason: string; items: string[] }): Promise<Record<string, unknown>> { return { returnId: `RET-${String(Date.now())}`, orderId: args.orderId, reason: args.reason, items: args.items, status: "pending_approval", requestedAt: new Date().toISOString(), };}export async function getReturnStatusExecute(args: { returnId: string }): Promise<Record<string, unknown>> { return { returnId: args.returnId, status: "in_progress", estimatedRefundDate: new Date(Date.now() + 5 * 86400000).toISOString(), refundAmount: 49.99, };}const initiateReturnTool = createTool({ id: "initiateReturn", description: "Initiate a return for an order", inputSchema: z.object({ orderId: z.string().min(1), reason: z.string().min(1), items: z.array(z.string().min(1)).min(1), }), execute: initiateReturnExecute,});const getReturnStatusTool = createTool({ id: "getReturnStatus", description: "Get the status of a return request", inputSchema: z.object({ returnId: z.string().min(1), }), execute: getReturnStatusExecute,});export const returnsAgent = new Agent({ id: "returns", name: "Returns Specialist", instructions: "You are a returns specialist for an e-commerce platform. " + "Your job is to help customers initiate returns and check return status. " + "Use the initiateReturn tool to start a new return request. " + "Use the getReturnStatus tool to check the status of an existing return. " + "Be helpful, concise, and accurate in your responses.", model: openai("gpt-4o-mini"), tools: { initiateReturn: initiateReturnTool, getReturnStatus: getReturnStatusTool },});
Each agent uses the createTool pattern from @mastra/core/tools. A tool has an id, a description the model uses to decide when to call it, an inputSchema for argument validation, and an execute function that runs the actual logic.
Step 6: Wire the agent map and create the handoff manager
Open src/agents/index.ts. This is the single place that resolves an agentId string to an actual Mastra agent instance.
ts
import { HandoffError } from "@reaatech/agent-handoff";import type { AgentCapabilities } from "@reaatech/agent-handoff";import type { Agent } from "@mastra/core/agent";import { orderLookupAgent } from "./order-lookup.js";import { shippingAgent } from "./shipping.js";import { returnsAgent } from "./returns.js";export { SPECIALIST_AGENTS, registry, getAgentCapabilities } from "./registry.js";export type { AgentCapabilities };type MastraAgent = Agent;const agentMap: Record<string, MastraAgent> = { "order-lookup": orderLookupAgent, shipping: shippingAgent, returns: returnsAgent,};export function getAgent(agentId: string): MastraAgent { const agent = agentMap[agentId]; if (!agent) { throw new HandoffError( `Agent not found: ${agentId}`, "routing_error", { agentId }, ); } return agent;}
Create src/agents/handoff-manager.ts. This orchestrates the handoff: compress the conversation context, route to the next specialist, and return a decision.
ts
import { randomUUID } from "node:crypto";import type { Message, CompressedContext } from "@reaatech/agent-handoff";import type { AgentRegistry } from "@reaatech/agent-handoff-routing";import { HandoffCompressor } from "../lib/handoff-compressor.js";import { routeHandoff } from "../lib/handoff-router.js";export interface HandoffResult { targetAgentId?: string; context: CompressedContext; type: "primary" | "clarification" | "fallback";}export async function executeHandoff( sessionId: string, currentAgentId: string, messages: Message[], registry: AgentRegistry,): Promise<HandoffResult> { const compressor = new HandoffCompressor(); const compressedContext = await compressor.compress(messages); const payload = { handoffId: randomUUID(), sessionId, conversationId: sessionId, sessionHistory: messages, compressedContext, handoffReason: { type: "specialist_required" as const, requiredSkills: [] as string[], currentAgentSkills: [] as string[], }, userMetadata: { userId: "unknown", }, conversationState: { resolvedEntities: {} as Record<string, unknown>, openQuestions: [] as string[], contextVariables: {} as Record<string, unknown>, }, createdAt: new Date(), }; const decision = await routeHandoff(payload, registry); switch (decision.type) { case "primary": { return { targetAgentId: decision.targetAgent.agentId, context: compressedContext, type: "primary", }; } case "clarification": { return { context: compressedContext, type: "clarification", }; } case "fallback": { return { context: compressedContext, type: "fallback", }; } }}
Step 7: Build the context compressor and handoff router
The context compressor summarizes the conversation history so the next agent does not need the full transcript. Create src/lib/handoff-compressor.ts.
Create src/lib/handoff-router.ts. This wires the CapabilityBasedRouter from the routing package.
ts
import { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";import type { HandoffPayload, RoutingDecision } from "@reaatech/agent-handoff";const capabilityRouter = new CapabilityBasedRouter({ minConfidenceThreshold: 0.7, ambiguityThreshold: 0.15, maxAlternatives: 3, policy: "best_effort",});export async function routeHandoff( payload: HandoffPayload, registry: AgentRegistry,): Promise<RoutingDecision> { return capabilityRouter.route(payload, registry.getAll());}
Step 8: Implement the order lookup service with typed errors
The src/lib/order.ts module wraps Stripe and ShipEngine calls with retry logic and typed errors. Open it.
ts
import Stripe from "stripe";import ShipEngine from "shipengine";import { HandoffError, withRetry } from "@reaatech/agent-handoff";import type { ReturnRequest } from "./schemas.js";export interface OrderData { orderId: string; status: string; items: string[]; total: number; createdAt: string;}export interface ShipmentData { trackingNumber: string; carrier
Notice that withRetry from @reaatech/agent-handoff is used with the shouldRetry callback that skips retries for validation_error codes. A “not found” error should fail fast, but a network blip should be retried.
Step 9: Set up session management
Open src/services/session.ts. This wraps the Firestore-backed session functions from the REAA session package.
ts
import { createSession, getActiveSession, getSessionById, appendTurn, closeSession, updateWorkflowState, resumeSession, resetFirestore,} from "@reaatech/agent-mesh-session";export { createSession, getActiveSession, getSessionById, appendTurn, closeSession, updateWorkflowState, resumeSession, resetFirestore,};import type { SessionRecord, TurnEntry, SessionStatus } from "@reaatech/agent-mesh";import { HandoffError } from "@reaatech/agent-handoff";export function buildTurnEntry( role: "user" | "agent", content: string, intentSummary?: string,): TurnEntry { return { role, content, timestamp: new Date().toISOString(), intent_summary: intentSummary, };}const VALID_SESSION_STATUSES: SessionStatus[] = [ "active", "completed", "abandoned", "error",];export function toSessionStatus(status: string): SessionStatus { if (!VALID_SESSION_STATUSES.includes(status as SessionStatus)) { throw new HandoffError( `Invalid session status: ${status}`, "validation_error", ); } return status as SessionStatus;}export async function requireActiveSession( userId: string,): Promise<SessionRecord> { const session = await getActiveSession(userId); if (!session) { throw new HandoffError("No active session", "transport_error"); } return session;}
The buildTurnEntry helper creates a properly shaped TurnEntry with an ISO timestamp. requireActiveSession throws a transport_error if the user has no active session.
Step 10: Build the chat API route
The main entry point is app/api/chat/route.ts (Next.js App Router). It handles both POST (send a message) and GET (check session status).
ts
import { NextRequest, NextResponse } from "next/server";import { HandoffError } from "@reaatech/agent-handoff";import { ChatRequestBodySchema, ChatResponseBodySchema } from "../../../src/lib/schemas.js";import { createSession, getActiveSession, getSessionById, appendTurn, closeSession,} from "../../../src/services/session.js";import { buildTurnEntry } from "../../../src/services/session.js";import { getAgent } from "../../../src/agents/index.js";import { logger } from "../../../src/lib/logger.js";export async function POST(request: NextRequest):
The POST handler validates the body, creates or resumes a session, calls the current specialist agent, appends both turns to the session, and checks for the workflow_complete flag. Error handling maps HandoffError codes to HTTP status codes: timeout_error gets 504, transport_error gets 502, and unknown codes get 500.
Step 11: Add observability with Langfuse
Open src/lib/observability.ts. This sets up Langfuse tracing for agent runs.
Create src/lib/logger.ts. Pino is already available as a dependency.
ts
import pino from "pino";export const logger = pino();
Step 12: Set up environment variable validation
Open src/lib/env.ts. This module validates all required environment variables at startup.
ts
import type { Env } from "@reaatech/agent-mesh";import { ConfigurationError } from "@reaatech/agent-handoff";export type { Env };const REQUIRED_VARS = [ "OPENAI_API_KEY", "STRIPE_SECRET_KEY", "SHIPENGINE_API_KEY", "GOOGLE_CLOUD_PROJECT",] as const;let _env: Env | null = null;let _envInitError: Error | null = null;function lazyEnv(): Env {
Step 13: Run the tests
With all source files in place, run the test suite.
terminal
pnpm test
The recipe has 119 tests across 15 test files. Your terminal should show all tests passing.
terminal
pnpm typecheck && pnpm lint
Expected output: no errors from either command.
Next steps
Add a frontend page at app/page.tsx that renders a chat interface. Use the POST /api/chat body format ({ message, sessionId?, userId }) and display the returned content field.
Replace mock tool executors in src/agents/order-lookup.ts, src/agents/shipping.ts, and src/agents/returns.ts with real calls to fetchOrder and trackShipment from src/lib/order.ts.
Wire handoffs into the route handler — when the order-lookup agent signals a topic shift, call executeHandoff from src/agents/handoff-manager.ts to compress context and route to the shipping or returns agent.
Set up Langfuse dashboards at https://cloud.langfuse.com to monitor agent run latency, token usage, and handoff frequency per specialist agent.
:
string
;
status: string;
events: Array<{
timestamp: string;
description: string;
location: string;
}>;
}
export class OrderNotFoundError extends HandoffError {