Azure AI Multi-Agent Handoff for E-commerce Customer Support
A routing mesh that classifies customer intents and hands off conversations between specialist AI agents — all hosted on Azure AI, so SMB e‑commerce teams can scale support without hiring.
E‑commerce SMBs field support queries that span order tracking, returns, product recommendations, and tech support. A single monolithic chatbot can't handle the breadth, and human agents are overwhelmed during sales spikes.
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.
You’ll build an Express server that routes e-commerce customer support messages between four specialist AI agents — returns, recommendations, order tracking, and tech support. Every incoming message passes through an intent classifier backed by Azure OpenAI, then hits a confidence gate that decides whether to route to the best agent, ask a clarifying question, or escalate to a human. The handoff between agents uses REAA’s agent-handoff-protocol to serialize conversation state and deliver it to the target specialist. By the end you’ll have a working /chat webhook, a full test suite with 90% coverage, and a working multi-agent handoff fabric you can extend with your own business logic.
Prerequisites
Node.js >= 22 — the recipe uses ES modules and engines.node is set to >=22
pnpm 10.0.0 — the packageManager field in package.json pins to this version
Azure OpenAI resource — you need an endpoint URL, API key, deployment name, and API version (default 2024-12-01-preview)
Familiarity with TypeScript, Express routing, and pnpm workspaces
Step 1: Scaffold the project
Create a new directory, initialize a pnpm project, and set up TypeScript and ESLint. The project uses "type": "module" for ESM, targets ES2022, and enforces strict typed lint rules.
terminal
mkdir azure-agent-mesh && cd azure-agent-meshpnpm init
Replace the generated package.json with the full project manifest:
The @reaatech/agent-handoff package provides the core TypeScript interfaces and error types. agent-handoff-routing gives you a capability-based router that scores agents by skills, domain expertise, and current load. agent-handoff-protocol handles context compression and transport delivery. agent-mesh-classifier wraps Azure OpenAI for intent classification, and agent-mesh-confidence provides a decision tree that evaluates classification results against per-agent thresholds.
Step 3: Configure environment variables
Create .env.example with every variable the recipe reads. Copy it to .env and fill in your Azure OpenAI credentials.
Now edit .env with your real Azure OpenAI values. AZURE_OPENAI_ENDPOINT should be your full endpoint URL (e.g., https://my-resource.openai.azure.com). AZURE_OPENAI_API_VERSION defaults to 2024-12-01-preview if left unset. HUMAN_ESCALATION_ENDPOINT is optional — if omitted, escalation responses just return null for the endpoint field.
Step 4: Create the foundation modules
Three modules form the bedrock: configuration loading, custom error types, and an in-memory session store. Create the src/ directory and add each file.
requireEnv uses REAA’s ConfigurationError to fail fast with a clear message if a required variable is missing. optionalEnv provides safe defaults so the server starts even when optional vars are absent.
Each error class carries a statusCode and optional details bag. The error handler in the server maps these to HTTP status codes — ClassificationError and AgentError return 500, while RoutingError and HandoffError return 502.
Create src/session.ts:
ts
import type { Message } from "@reaatech/agent-handoff";export interface SessionState { sessionId: string; activeAgentId: string; conversationHistory: Message[]; createdAt: Date; lastActiveAt: Date;}const sessions = new Map<string, SessionState>();export function getSession(sessionId: string): SessionState | undefined { return sessions.get(sessionId);}export function createSession(sessionId: string): SessionState { const now = new Date(); const session: SessionState = { sessionId, activeAgentId: "", conversationHistory: [], createdAt: now, lastActiveAt: now, }; sessions.set(sessionId, session); return session;}export function updateSession( sessionId: string, updates: Partial<SessionState>,): SessionState { const existing = sessions.get(sessionId); if (!existing) { throw new Error(`Session not found: ${sessionId}`); } const updated: SessionState = { ...existing, ...updates, lastActiveAt: new Date(), }; sessions.set(sessionId, updated); return updated;}export function deleteSession(sessionId: string): void { sessions.delete(sessionId);}
The session store is an in-memory Map keyed by sessionId. Each session tracks the currently active agent, accumulated conversation history, and timestamps. When a handoff routes to a specialist, updateSession records the new activeAgentId so follow-up messages in the same session stay with that agent.
Step 5: Define the agent registry and handlers
The agent registry declares four specialist agents — returns, recommendations, order tracking, and tech support — each with skills, domains, languages, and confidence thresholds. The handler module wraps Azure OpenAI calls to generate responses from each specialist’s system prompt.
Create the src/agents/ directory, then create src/agents/registry.ts:
The defaultAgent constant defines a standalone tech-support fallback — separate from the specialists list in specialistAgents — that the classifier can use as a default when no agent matches confidently. The confidence_threshold in metadata (0.7) is the per-agent cutoff for direct routing. If the classifier’s confidence drops below that, the confidence gate triggers clarification or escalation instead.
Create src/agents/handlers.ts:
ts
import "@azure/openai";import { AzureOpenAI } from "openai/azure";import type { Message } from "@reaatech/agent-handoff";import { config } from "../config.js";import { AgentError } from "../errors.js";export async function handleAgentQuery( agentId: string, messages: Message[], context: Record<string, unknown>,): Promise<string> { const client = new AzureOpenAI({ apiKey: config.azureOpenAiApiKey, endpoint: config.azureOpenAiEndpoint, apiVersion: config.azureOpenAiApiVersion, deployment: config.azureOpenAiDeploymentName, }); const systemMessage = buildSystemPrompt(agentId, context); const conversationMessages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [ { role: "system", content: systemMessage }, ...messages.map((m) => ({ role: m.role as "user" | "assistant", content: m.content, })), ]; try { const response = await client.chat.completions.create({ model: config.azureOpenAiDeploymentName, messages: conversationMessages, max_tokens: 1024, }); const choice = response.choices[0]; if (!choice || !choice.message.content) { throw new AgentError("Azure OpenAI returned an empty response"); } return choice.message.content; } catch (error: unknown) { if (error instanceof AgentError) { throw error; } throw new AgentError( `Azure OpenAI request failed for agent ${agentId}: ${ error instanceof Error ? error.message : String(error) }`, { agentId, error: String(error) }, ); }}function buildSystemPrompt( agentId: string, context: Record<string, unknown>,): string { const contextStr = Object.keys(context).length > 0 ? `\nContext: ${JSON.stringify(context)}` : ""; const prompts: Record<string, string> = { returns: "You are a Returns Specialist for an e-commerce store. Help customers with " + "return requests, refunds, exchanges, and order cancellations. Be empathetic " + "and ask for order numbers when needed." + contextStr, recommendations: "You are a Product Recommendations Specialist for an e-commerce store. " + "Help customers find products, compare options, and make purchasing decisions. " + "Ask about their needs, budget, and preferences." + contextStr, "order-tracking": "You are an Order Tracking Specialist for an e-commerce store. Help customers " + "check order status, track shipments, and estimate delivery times. Ask for their " + "order number if needed." + contextStr, "tech-support": "You are a Tech Support Specialist for an e-commerce store. Help customers with " + "account issues, website problems, password resets, and technical troubleshooting." + contextStr, }; return prompts[agentId] ?? prompts["tech-support"] ?? "You are a helpful e-commerce support agent.";}
The import "@azure/openai" side-effect import extends OpenAI’s types with Azure-specific properties. buildSystemPrompt tailors the system message to each specialist, and falls back to the tech-support prompt for unknown agent IDs.
Step 6: Wire up the intent classifier
The classifier calls @reaatech/agent-mesh-classifier to label incoming messages with an agent ID, confidence score, and intent summary. Since the classifier service expects a different agent shape than the routing registry, a converter remaps AgentCapabilities to the classifier’s AgentConfig format.
The classifierService.classify() call is the single integration point with the agent-mesh-classifier package. It automatically handles prompt construction and language detection internally. The tech-support agent is marked as the default — it catches ambiguous messages when the classifier can’t confidently pick a specialist.
Step 7: Build the confidence gate
The confidence gate takes the classifier’s output and decides what to do: route to the target agent, ask a clarifying question, or escalate to a human. It uses @reaatech/agent-mesh-confidence’s decision tree, which compares the classification confidence against each agent’s threshold.
Create src/mesh/confidence-gate.ts:
ts
import { evaluateConfidenceGate } from "@reaatech/agent-mesh-confidence";import type { AgentRegistry } from "@reaatech/agent-handoff-routing";import type { AgentCapabilities } from "@reaatech/agent-handoff";import type { ClassifierOutput } from "./classifier.js";export interface ConfidenceDecision { action: "route" | "clarify" | "fallback"; agent_id: string; confidence: number; clarification_question?: string; reason: string;}function toConfidenceRegistry(agents: AgentCapabilities[]): Array<{ agent_id: string; display_name: string; description: string; endpoint: string; type: "mcp"; is_default: boolean; confidence_threshold: number; clarification_required: boolean; examples: string[];}> { return agents.map((a) => ({ agent_id: a.agentId, display_name: a.agentName, description: a.domains.join(", "), endpoint: `/api/agents/${a.agentId}`, type: "mcp" as const, is_default: false, confidence_threshold: ((a as { metadata?: Record<string, unknown> }).metadata?.confidence_threshold ?? 0.7) as number, clarification_required: ((a as { metadata?: Record<string, unknown> }).metadata?.clarification_required ?? true) as boolean, examples: a.skills, }));}export function evaluateConfidence( classification: ClassifierOutput, registry: AgentRegistry, isActiveSession: boolean,): ConfidenceDecision { const adapted = toConfidenceRegistry(registry.getAll()); const decision = evaluateConfidenceGate( classification, adapted, isActiveSession, ); return decision as ConfidenceDecision;}
When isActiveSession is true — meaning the user has an ongoing conversation with a specialist — the confidence gate passes the flag through to evaluateConfidenceGate, which bypasses the threshold check and routes directly to the session’s assigned agent. This keeps multi-turn conversations with the same agent flowing without re-classification friction.
Step 8: Set up routing and handoff orchestration
The router uses @reaatech/agent-handoff-routing’s CapabilityBasedRouter with a weighted scoring algorithm. The handoff orchestration initializes a HandoffManager from @reaatech/agent-handoff-protocol, wires up lifecycle event logging, and executes handoffs through an A2A transport.
Create src/mesh/router.ts:
ts
import { CapabilityBasedRouter } from "@reaatech/agent-handoff-routing";import type { HandoffPayload, RoutingDecision, AgentCapabilities } from "@reaatech/agent-handoff";export const router = new CapabilityBasedRouter({ minConfidenceThreshold: 0.7, ambiguityThreshold: 0.15, maxAlternatives: 3, policy: "best_effort",});export async function routeMessage( handoffPayload: HandoffPayload, agents: AgentCapabilities[],): Promise<RoutingDecision> { return router.route(handoffPayload, agents);}
The router scores each registered agent on four axes: skill match, domain expertise, current load, and language support. The best_effort policy means it returns the highest-scoring agent even if no match is perfect.
Create src/mesh/handoff.ts:
ts
import { HandoffManager, createHandoffConfig, HybridCompressor, TransportFactory, A2ATransport,} from "@reaatech/agent-handoff-protocol";import { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";import type { Message, HandoffTrigger, HandoffResult, AgentCapabilities,} from "@reaatech/agent-handoff";let manager: HandoffManager | undefined;let routingRegistry: AgentRegistry | undefined;function structuredLog(level: "info" | "warn" |
The HybridCompressor handles context compression before sending messages to the target agent — important when conversation history grows long. Lifecycle events (handoffStart, handoffComplete, handoffReject, handoffError) emit structured JSON logs so you can trace every handoff end-to-end.
Step 9: Build the Express server
The server exposes a single POST /chat endpoint that ties the entire mesh together: load dotenv, parse the request body, classify the message, evaluate confidence, and either execute a handoff, respond with a clarification question, or escalate.
Create src/server.ts:
ts
import "dotenv/config";import express from "express";import type { NextFunction, Request, Response } from "express";import { HandoffError as ReaaHandoffError } from "@reaatech/agent-handoff";import type { Message } from "@reaatech/agent-handoff";import { config } from "./config.js";import { createAgentRegistry } from "./agents/registry.js";import { getSession, createSession, updateSession } from "./session.js";import { initHandoffManager, performHandoff } from "./mesh/handoff.js";import { classifyIntent } from "./mesh/classifier.js";import { evaluateConfidence }
The error-handling middleware maps REAA error codes to HTTP status codes: transport_error and routing_error produce 502, timeout_error produces 504, validation_error produces 400, and rejection_error produces 503. Malformed JSON bodies get a 400 with a clear message. The app is exported so the test suite can import it directly with supertest.
Step 10: Run the tests
The test suite uses Vitest with v8 coverage and 90% thresholds. A setup file injects test environment variables so the REAA packages don’t fail on startup. Create the test infrastructure and run it.
Create tests/setup.ts:
ts
// Setup file that runs before all tests// Set required env vars to prevent @reaatech/agent-mesh from calling process.exit(1)process.env.GOOGLE_CLOUD_PROJECT = "test-project";process.env.API_KEY = "test-api-key";process.env.AZURE_OPENAI_ENDPOINT = "https://test.openai.azure.com";process.env.AZURE_OPENAI_API_KEY = "test-key";process.env.AZURE_OPENAI_DEPLOYMENT_NAME = "test-deployment";process.env.NODE_ENV = "test";
Expected output: Vitest runs all test files in tests/ — classifier tests, confidence gate tests, handler tests, handoff lifecycle tests, session tests, registry tests, and server integration tests. The terminal prints a coverage summary with at least 90% across lines, branches, functions, and statements. You’ll see individual test results like:
code
✓ POST /chat without message returns 400
✓ POST /chat with message returns a response
✓ high confidence -> route action
✓ low confidence -> clarify or fallback
✓ happy path: performHandoff with valid data -> HandoffResult success true
To typecheck:
terminal
pnpm typecheck
To build the production output:
terminal
pnpm build
Expected output: TypeScript compiles src/ to dist/ using the NodeNext module system. Start the server with pnpm start and send a test request:
terminal
curl -X POST http://localhost:3000/chat \ -H "Content-Type: application/json" \ -d '{"message":"I want to return my order","sessionId":"s1"}'
Expected output: The response includes an action field — either "route" with an agent_id and confidence, "clarify" with a clarification question, or "escalate" with a reason.
Next steps
Swap the in-memory session store for Redis or a database so sessions survive server restarts
Wire each specialist agent to real order/returns/recommendations APIs instead of the generic Azure OpenAI system prompts — the handleAgentQuery function already accepts a context bag for passing order IDs or customer data
Deploy the Express server behind Azure API Management with rate limiting and authentication — the config.ts module centralizes all environment-specific values so you only touch one file