OpenAI Multi-Agent Handoff for E-commerce Order Handling
Handle order status, modifications, and cancellations via a Mastra-orchestrated multi-agent system with OpenAI, preventing duplicate refunds and runaway costs.
Small e-commerce teams are overwhelmed by repetitive order support requests and risk double-refunding customers when multiple agents manually process the same issue.
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.
In this tutorial you’ll build a multi-agent order handling system for e-commerce customer support. Three specialist agents — Order Status, Order Modifications, and Cancellations & Refunds — work together under a Mastra-orchestrated workflow, with intent classification from OpenAI’s chat completions API. You’ll add an LLM response cache to handle repeated queries instantly, a per-conversation budget engine to prevent runaway costs, and an idempotency layer on the refund endpoint so a refund is never processed twice. By the end, you’ll have a Next.js app with four API endpoints that surface conversation spend and budget states.
Prerequisites
Node.js >= 22 and pnpm >= 10
An OpenAI API key with access to gpt-5.2-mini (set in .env)
A Stripe account for charge lookups and refunds (test mode is fine)
A Supabase project with conversations and spend_records tables
A Langfuse project for observability (optional — the app runs without it)
Familiarity with Next.js App Router route handlers, TypeScript, and basic Zod schema validation
Step 1: Scaffold the Next.js project and install dependencies
Start with a fresh Next.js 16 project using the App Router, then install every dependency with exact versions.
Expected output:pnpm add resolves all packages without peer-dependency warnings. Your package.jsondependencies and devDependencies blocks should contain exactly the versions above with no ^ or ~ prefixes.
Step 2: Configure environment variables
Create your .env file from the .env.example and fill in the placeholders. The app reads these values at startup through process.env.
terminal
cp .env.example .env
Populate .env with your real credentials. The .env.example file contains these entries:
Replace each <your-...> placeholder with your actual credentials. BUDGET_LIMIT_PER_CONVERSATION=2.0 sets a $2.00 cap on each conversation. Langfuse credentials are optional — the app instantiates the client lazily and won’t crash if the env vars are missing.
Step 3: Define the domain types
Create the shared TypeScript types that the agents and routes will use. These live under src/types/.
Expected output: Three new files under src/types/. The OrderStatus union prevents invalid status strings, and Conversation.messages carries the turn history that gets persisted to Supabase after each API call.
Step 4: Build the pricing provider and spend store
The pricing provider maps model names to per-token costs. The spend store records and queries costs against Supabase.
Expected output: Calling pricingProvider.estimateCost({ model: "gpt-5.2", inputTokens: 1_000_000, outputTokens: 1_000_000 }) returns 40 (10 + 30 US dollars per million tokens). Unknown models return 0.
Now create the Supabase-backed spend store at src/services/spend-store.ts:
ts
import { getSupabaseClient } from "../lib/supabase.js";export class SpendStore { async getSpent(scopeType: string, scopeKey: string): Promise<number> { const supabase = getSupabaseClient(); const { data, error } = await supabase .from("spend_records") .select("cost") .eq("scope_type", scopeType) .eq("scope_key", scopeKey); if (error) return 0; return data.reduce((sum: number, row: unknown) => { const r = row as { cost?: number }; return sum + (r.cost ?? 0); }, 0); } async recordSpend( scopeType: string, scopeKey: string, amount: number, metadata?: Record<string, unknown>, ): Promise<void> { const supabase = getSupabaseClient(); const record: Record<string, unknown> = { scope_type: scopeType, scope_key: scopeKey, cost: amount, recorded_at: new Date().toISOString(), metadata, }; const { error } = await supabase.from("spend_records").insert(record as never); if (error) throw new Error(`recordSpend failed: ${error.message}`); } async getState( scopeType: string, scopeKey: string, ): Promise<{ spent: number; remaining: number; state: string }> { const spent = await this.getSpent(scopeType, scopeKey); const limit = parseFloat(process.env.BUDGET_LIMIT_PER_CONVERSATION ?? "2.0"); const remaining = Math.max(0, limit - spent); let state = "Active"; if (spent >= limit) state = "Stopped"; else if (spent >= limit * 0.9) state = "Degraded"; else if (spent >= limit * 0.8) state = "Warned"; return { spent, remaining, state }; }}
Expected output: A new conversation starts with getState returning { spent: 0, remaining: 2.0, state: "Active" }. Each recordSpend call inserts a row into your spend_records Supabase table.
Step 5: Set up the database and Stripe clients
Create the Supabase client at src/lib/supabase.ts:
ts
import { createClient } from "@supabase/supabase-js";import type { Conversation } from "../types/chat.js";export interface ConversationRow extends Conversation { updatedAt: Date;}let client: ReturnType<typeof createClient> | null = null;export function getSupabaseClient() { if (!client) { const url = process.env.SUPABASE_URL ?? ""; const key = process.env.SUPABASE_SECRET_KEY ?? ""; client = createClient(url, key); } return client;}export async function upsertConversation( id: string, data: Partial<ConversationRow>,): Promise<void> { const supabase = getSupabaseClient(); const row: Record<string, unknown> = { id, ...data, updatedAt: new Date().toISOString() }; const { error } = await supabase .from("conversations") .upsert(row as never); if (error) throw new Error(`Supabase upsert failed: ${error.message}`);}export async function getConversation( id: string,): Promise<ConversationRow | null> { const supabase = getSupabaseClient(); const { data, error } = await supabase .from("conversations") .select("*") .eq("id", id) .single(); if (error) return null; return data as ConversationRow;}export async function listConversations(): Promise<ConversationRow[]> { const supabase = getSupabaseClient(); const { data, error } = await supabase .from("conversations") .select("*") .order("updatedAt", { ascending: false }); if (error) throw new Error(`Supabase list failed: ${error.message}`); return data as ConversationRow[];}
Expected output:lookupOrder("ch_test123") calls Stripe’s Charges API and returns { orderId, amount, currency, status }. processRefund("ch_test123", 5000) creates a Stripe refund of $50.00 (amount in cents).
Step 6: Wire up the LLM cache and budget engine
The cache returns instant responses for repeated queries. Create src/lib/cache.ts:
Expected output: When a conversation starts, defineConversationBudget("conv-abc") sets a $2.00 budget. checkBudget("conv-abc", 1.50, "gpt-5.2", ["lookupOrder"]) returns { allowed: true } at 75% utilisation and { suggestedModel: "gpt-5.2-mini" } when soft cap (80%) is breached.
Step 7: Configure intent classification with the agent mesh
The mesh config registers the three specialist agents and provides a function that calls OpenAI to classify each user message. Create src/lib/mesh-config.ts:
ts
import OpenAI from "openai";import type { AgentConfig, ClassifierOutput } from "@reaatech/agent-mesh";const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });export function getAgentConfig(): AgentConfig[] { return [ { agent_id: "order-status-agent", display_name: "Order Status", description: "Look up order status and tracking information", endpoint: "/api/agents/order-status", type: "mcp", confidence_threshold: 0.7, examples: ["Where is my order?", "What's my order status?"], }, { agent_id: "modification-agent", display_name: "Order Modifications", description: "Modify shipping address, items, or delivery instructions", endpoint: "/api/agents/modification", type: "mcp", confidence_threshold: 0.7, examples: ["Change my shipping address", "Add an item to my order"], }, { agent_id: "cancellation-agent", display_name: "Cancellations & Refunds", description: "Cancel orders and issue refunds", endpoint: "/api/agents/cancellation", type: "mcp", confidence_threshold: 0.7, examples: ["Cancel my order", "I want a refund"], }, ] as AgentConfig[];}export async function classifyIntent( input: string,): Promise<ClassifierOutput> { const agentIds = getAgentConfig().map((a) => a.agent_id).join(", "); const completion = await openai.chat.completions.create({ model: "gpt-5.2-mini", messages: [ { role: "developer", content: `You are an intent classifier. Classify the user's request into one of these agent IDs: ${agentIds}. Return a JSON object with keys: agent_id (string), confidence (number 0-1), ambiguous (boolean), intent_summary (string), entities (object).`, }, { role: "user", content: input }, ], response_format: { type: "json_object" }, }); const text = completion.choices[0]?.message?.content ?? "{}"; return JSON.parse(text) as ClassifierOutput;}
Expected output:classifyIntent("Where is my order #123?") calls OpenAI’s chat completions API with a gpt-5.2-mini model and returns a ClassifierOutput with agent_id: "order-status-agent" and a confidence score.
Step 8: Build the three specialist agents
Each agent is a focused module that performs one job. Start with the status agent at src/services/status-agent.ts:
ts
import { lookupOrder as stripeLookupOrder } from "../lib/stripe.js";export async function lookupOrder( chargeId: string,): Promise<{ found: boolean; message: string; order?: unknown }> { if (!chargeId) return { found: false, message: "No order ID provided" }; try { const order = await stripeLookupOrder(chargeId); return { found: true, message: `Order #${order.orderId} — Status: ${order.status} — Total: $${(order.amount / 100).toFixed(2)}`, order, }; } catch { return { found: false, message: "Order not found" }; }}export async function executeAgent(input: string): Promise<string> { const match = input.match(/order\\s*#?\\s*(\\S+)/i); const chargeId = match?.[1]; if (!chargeId) return "Please provide an order ID."; const result = await lookupOrder(chargeId); return result.message;}
Create the modification agent at src/services/modification-agent.ts:
ts
import { getSupabaseClient } from "../lib/supabase.js";export async function modifyOrder( orderId: string, changes: Record<string, unknown>,): Promise<{ success: boolean; message: string; updatedOrder?: unknown }> { if (!orderId) return { success: false, message: "No order ID provided" }; if (Object.keys(changes).length === 0) { return { success: false, message: "No changes provided" }; } const hasPrice = Object.keys(changes).some( (k) => k.toLowerCase() === "price" || k.toLowerCase() === "total", ); if (hasPrice) { return { success: false, message: "price changes require manual review" }; } const supabase = getSupabaseClient(); const { data, error } = await supabase .from("orders") .update(changes as never) .eq("orderId", orderId) .select() .single(); if (error) return { success: false, message: `Update failed: ${error.message}` }; return { success: true, message: "Order updated", updatedOrder: data };}export async function executeAgent(input: string): Promise<string> { const match = input.match(/order\\s*#?\\s*(\\S+)/i); const orderId = match?.[1]; if (!orderId) return "Please provide an order ID."; const changesMatch = input.match(/(\\w+)\\s*(?:to|:|=)\\s*["']?([^"',]+)["']?/i); const changes: Record<string, unknown> = changesMatch ? { [changesMatch[1]]: changesMatch[2].trim() } : { shippingAddress: input.replace(/change\\s+(my\\s+)?order\\s+#?\\S+/i, "").trim() || "Updated by agent" }; const result = await modifyOrder(orderId, changes); return result.message;}
Create the cancellation agent at src/services/cancellation-agent.ts:
ts
import { getSupabaseClient } from "../lib/supabase.js";import { processRefund as stripeRefund } from "../lib/stripe.js";import { executeIdempotent } from "../lib/idempotency.js";export async function cancelOrder( orderId: string, reason: string,): Promise<{ success: boolean; message: string }> { if (!orderId) return { success: false, message: "No order ID provided" }; const supabase = getSupabaseClient(); const { data: order, error: fetchError } = await supabase .from("orders") .select("*") .eq("orderId", orderId) .single(); if (fetchError) { return { success: false, message: "Order not found" }; } const ord = order as { status: string }; if (ord.status === "shipped" || ord.status === "delivered") { return { success: false, message: "order already completed" }; } const { error: updateError } = await supabase .from("orders") .update({ status: "cancelled", cancellationReason: reason } as never) .eq("orderId", orderId); if (updateError) { return { success: false, message: `Cancel failed: ${updateError.message}` }; } return { success: true, message: "Order cancelled" };}export async function issueRefund( chargeId: string, amount: number,): Promise<{ success: boolean; refundId: string; status: string }> { const result = await executeIdempotent( `refund-${chargeId}`, { method: "POST", path: "/api/refund", body: { chargeId, amount } }, async () => stripeRefund(chargeId, amount), ); return result as { success: boolean; refundId: string; status: string };}export async function executeAgent(input: string): Promise<string> { const match = input.match(/order\\s*#?\\s*(\\S+)/i); const orderId = match?.[1]; if (!orderId) return "Please provide an order ID."; const cancel = await cancelOrder(orderId, "Customer request"); if (!cancel.success) return cancel.message; const chargeMatch = input.match(/charge\\s*#?\\s*(\\S+)/i); if (chargeMatch?.[1]) { const refund = await issueRefund(chargeMatch[1], 0); return `Order ${orderId} has been cancelled. Refund: ${refund.status}`; } return `Order ${orderId} has been cancelled.`;}
Expected output:cancelOrder("ord-1", "customer request") on a pending order returns { success: true, message: "Order cancelled" }. modifyOrder("ord-1", { price: 50 }) returns { success: false, message: "price changes require manual review" }. lookupOrder("ch_unknown") catches the Stripe error and returns { found: false, message: "Order not found" }.
Step 9: Wire up the Mastra workflow and idempotency middleware
The idempotency middleware prevents duplicate refunds. Create src/lib/idempotency.ts:
Now create the Mastra workflow at src/services/mastra-workflow.ts. This is the central orchestrator that chains intent classification, budget check, cache lookup, and agent execution:
ts
import { classifyIntent } from "../lib/mesh-config.js";import { checkBudget, recordSpend } from "../lib/budget.js";import { getCached, setCached } from "../lib/cache.js";import { lookupOrder } from "./status-agent.js";import { modifyOrder } from "./modification-agent.js";import { cancelOrder, issueRefund } from "./cancellation-agent.js";import { z } from "zod";export const tools = { lookupOrder: { name: "lookupOrder", description: "Look up order status and tracking information", parameters: z.object({ orderId: z.string() }),
Expected output:processUserMessage("conv-1", "Where is my order?") classifies the intent, checks the budget (passes because the conversation is new), misses the cache, calls the status agent, caches the reply, records a $0.005 spend, and returns { reply, agentId: "order-status-agent", spend: 0.005, ... }.
Step 10: Create the API routes
The chat route at app/api/chat/route.ts receives user messages, validates them with Zod, initialises conversations, and delegates to the Mastra workflow:
ts
import { type NextRequest, NextResponse } from "next/server";import { processUserMessage } from "../../../src/services/mastra-workflow.js";import { getConversation, upsertConversation } from "../../../src/lib/supabase.js";import { defineConversationBudget } from "../../../src/lib/budget.js";import { z } from "zod";const RequestSchema = z.object({ conversationId: z.string().min(1), message: z.string().min(1),});export async function POST(req: NextRequest): Promise<NextResponse> { try { const body = await req.json() as Record<string, unknown>; const parsed = RequestSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "invalid_request", details: parsed.error.issues }, { status: 400 }, ); } const { conversationId, message } = parsed.data; let conversation = await getConversation(conversationId); if (!conversation) { defineConversationBudget(conversationId); await upsertConversation(conversationId, { conversationId, messages: [], status: "active", totalSpend: 0, } as Record<string, unknown> as Parameters<typeof upsertConversation>[1]); conversation = await getConversation(conversationId); } const history = (conversation?.messages ?? []) as Array<{ role: string; content: string }>; const result = await processUserMessage(conversationId, message); if (result.agentId === "budget-guard") { return NextResponse.json( { error: "budget_exceeded", conversationId }, { status: 429 }, ); } const newMessages = [ ...history, { role: "user", content: message, timestamp: new Date().toISOString() }, { role: "assistant", content: result.reply, timestamp: new Date().toISOString() }, ]; await upsertConversation(conversationId, { messages: newMessages, totalSpend: (conversation?.totalSpend ?? 0) + result.spend, } as Record<string, unknown> as Parameters<typeof upsertConversation>[1]); return NextResponse.json({ reply: result.reply, agentId: result.agentId, conversationId, spend: result.spend, }); } catch (error) { const message = error instanceof Error ? error.message : "Internal server error"; return NextResponse.json({ error: message }, { status: 500 }); }}
The refund route at app/api/refund/route.ts processes refunds with idempotency key protection:
Expected output: Sending POST /api/chat with { "conversationId": "test-1", "message": "Where is my order?" } returns { reply: "...", agentId: "order-status-agent", conversationId: "test-1", spend: 0.005 }. Sending POST /api/refund with a valid Idempotency-Key header and { "orderId": "ch_123", "amount": 5000, "reason": "Customer request" } returns { success: true, refundId: "re_...", status: "succeeded" }. Sending the same request again returns the cached result without hitting Stripe twice.
Step 11: Run the tests
This recipe ships with a vitest suite covering every service, lib module, and API route. Run it with coverage:
terminal
pnpm test
Expected output: All 109 tests pass (numFailedTests = 0) with line, branch, function, and statement coverage above 90% across the full codebase. The coverage report includes the pricing provider, spend store, cache, budget, mesh config, all three specialist agents, Mastra workflow, Supabase and Stripe clients, idempotency middleware, observability module, and all four API routes.
You can also run type-checking and linting:
terminal
pnpm typecheckpnpm lint
Expected output:pnpm typecheck exits 0 with no errors. pnpm lint exits 0 with no warnings.
Next steps
Add a Supabase webhook or Edge Function that triggers processUserMessage for SMS-based order inquiries, sending replies back via Twilio
Extend the agent mesh with additional specialist agents — a returns-agent for return authorisation labels or a fraud-review-agent that flags suspicious cancellation patterns
Replace the InMemoryAdapter in the cache and idempotency middleware with a Redis adapter for production persistence across multiple server instances
Add rate limiting per IP to the chat endpoint to prevent abuse of the OpenAI API
Build a live-updating dashboard using Server-Sent Events that pushes conversation turn notifications as they arrive
execute
:
async
({ orderId }
:
{ orderId
:
string
})
=>
lookupOrder
(orderId),
},
modifyOrder: {
name: "modifyOrder",
description: "Modify order shipping address, items, or delivery instructions",