Small businesses often deploy separate AI chatbots for sales, support, and booking, leading to fragmented interactions, lost context, and unpredictable LLM costs when agents overlap.
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 a multi-agent handoff mesh that routes customer messages to specialized AI agents — sales, support, or booking — while tracking costs and preserving conversation context across handoffs. By the end you’ll have a working Express server with a POST /api/v1/request endpoint, a WebSocket streaming channel, a budget controller that prevents overspend, and a handoff manager that transfers context seamlessly between agents. You’ll wire everything together with the @reaatech/agent-mesh-* ecosystem and run a full test suite to verify each layer works.
Prerequisites
Node.js >= 22 (required by the engines field in package.json)
pnpm 1.10 — the project uses pnpm@10.15.0 as its package manager
A terminal and a code editor
Familiarity with TypeScript and Express routing
An API_KEY value (you’ll set it in .env — use dev-key for local development)
Step 1: Scaffold the project and install dependencies
Create an empty directory, a package.json, and install everything in one go.
You’ll see no output (the src/ directory is empty, so there’s nothing to check yet). As you add source files, this command will catch type errors early.
Step 3: Set environment variables and the config module
Create src/budget.ts. This module wraps LLM calls with spend tracking: a file-backed store loads previous spend on startup, a BudgetController performs pre-flight checks, a DefaultPricingProvider maps model IDs to per-token costs, and initBudget registers a $10.00 budget with auto-downgrade and tool-disabling policies:
ts
import * as fs from "node:fs";import { BudgetController } from "@reaatech/agent-budget-engine";import { config } from "./config.js";import { logger } from "./logger.js";interface SpendRecord { [scopeKey: string]: number;}interface SpendData { [scopeType: string]: SpendRecord;}class DefaultPricingProvider { private pricingTable: Record<string, { input: number
The initBudget function sets a $10.00 per-user limit. At 80% spend a warning fires; at 100% the controller blocks further requests. The autoDowngrade policy drops premium models to gpt-4o-mini and disables web-search-premium.
Step 6: Create the agent router
Create src/lib/router.ts. This module resolves an agent from the registry, checks the budget, dispatches via MCP, and records spend:
ts
import { registryState } from "@reaatech/agent-mesh-registry";import { dispatchToAgent } from "@reaatech/agent-mesh-router";import { budgetController } from "../budget.js";export class BudgetExceededError extends Error { constructor(message: string) { super(message); this.name = "BudgetExceededError"; }}export function resolveAgent(intent: string): unknown { const agent = registryState.getAgent(intent); if (agent) { return agent; } return registryState.defaultAgent;}export async function routeRequest( agentConfig: unknown, inputPayload: { sessionId: string; employeeId: string; displayName: string; rawInput: string; intentSummary: string; entities: Record<string, unknown>; detectedLanguage: string; turnHistory: unknown[]; workflowState: Record<string, unknown>; },): Promise<unknown> { const agentId = typeof agentConfig === "object" && agentConfig !== null && "agentId" in agentConfig ? (agentConfig as { agentId: string }).agentId : "default"; const budgetCheck = budgetController.check({ scopeType: "User" as never, scopeKey: agentId, estimatedCost: 0.05, modelId: "default-model", tools: [], }); if (!budgetCheck.allowed) { throw new BudgetExceededError("Budget exceeded"); } try { const response = await dispatchToAgent(agentConfig as never, { sessionId: inputPayload.sessionId, employeeId: inputPayload.employeeId, displayName: inputPayload.displayName, rawInput: inputPayload.rawInput, intentSummary: inputPayload.intentSummary, entities: inputPayload.entities, detectedLanguage: inputPayload.detectedLanguage, turnHistory: inputPayload.turnHistory as never, workflowState: inputPayload.workflowState, }); budgetController.record({ requestId: crypto.randomUUID(), scopeType: "User" as never, scopeKey: agentId, cost: 0.05, inputTokens: 0, outputTokens: 0, modelId: "default-model", provider: "unknown", timestamp: new Date(), }); return response; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("Circuit breaker OPEN")) { throw Object.assign(new Error("Circuit breaker OPEN"), { statusCode: 503 }); } if (message.includes("timeout")) { throw Object.assign(new Error("MCP request timeout"), { statusCode: 504 }); } if (message.includes("Agent response did not match")) { throw Object.assign(new Error("Agent response did not match schema"), { statusCode: 502 }); } if (message.includes("Failed to dispatch")) { throw Object.assign(new Error("Failed to dispatch request to agent"), { statusCode: 502 }); } throw err; }}
resolveAgent looks up an agent by intent. routeRequest runs a pre-flight budget check, dispatches the request through dispatchToAgent (which handles MCP connection pooling, retries, and circuit breaking), records the spend on success, and translates dispatch errors into HTTP status codes.
Step 7: Add the handoff manager
Create src/handoff.ts. This module builds a HandoffManager from @reaatech/agent-handoff-protocol with a capability-based router, a hybrid compressor, and MCP transport. The triggerHandoff function constructs a HandoffContext and executes the transfer:
The handoff manager emits lifecycle events (handoffStart, handoffComplete, handoffReject, handoffError). The HybridCompressor compresses long conversation histories before transfer, and the CapabilityBasedRouter picks the best receiving agent.
Step 8: Build the API gateway route and WebSocket manager
Create src/api/gateway/route.ts. This Express router applies CORS, rate limiting, and authentication middleware, then delegates to the mesh gateway’s handleRequest:
ts
import { Router, type Request, type Response } from "express";import cors from "cors";import { authMiddleware, rateLimiterMiddleware, handleRequest } from "@reaatech/agent-mesh-gateway";import { config } from "../../config.js";import { logger } from "../../logger.js";const router: Router = Router();const allowedOrigins = config.CORS_ALLOW_ORIGIN.split(",").map((s) => s.trim());const corsOptions = { origin: function (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) { if (!origin || allowedOrigins.includes(origin) || allowedOrigins.includes("*")) { callback(null, true); } else { callback(new Error("Not allowed by CORS")); } }, credentials: true,};router.use(cors(corsOptions));router.use(rateLimiterMiddleware);router.post( "/v1/request", authMiddleware, async (req: Request, res: Response) => { try { await handleRequest(req, res); } catch (err) { logger.error("gateway", "Request handler failed", err); res.status(500).json({ error: "Internal Server Error", message: "An unexpected error occurred", }); } },);export { router };
Next, create src/ws.ts for WebSocket session management:
ts
import { WebSocket } from "ws";export class ConnectionManager { private connections: Map<string, Set<WebSocket>> = new Map(); addConnection(sessionId: string, ws: WebSocket): void { let sessionSet = this.connections.get(sessionId); if (!sessionSet) { sessionSet = new Set(); this.connections.set(sessionId, sessionSet); } sessionSet.add(ws); ws.on("close", () => { this.removeConnection(sessionId, ws); }); ws.on("error", () => { this.removeConnection(sessionId, ws); }); } removeConnection(sessionId: string, ws: WebSocket): void { const sessionSet = this.connections.get(sessionId); if (!sessionSet) return; sessionSet.delete(ws); if (sessionSet.size === 0) { this.connections.delete(sessionId); } } broadcast(sessionId: string, event: { type: string; payload: unknown }): void { const sessionSet = this.connections.get(sessionId); if (!sessionSet) return; const data = JSON.stringify(event); for (const ws of Array.from(sessionSet)) { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } } } closeAll(): void { for (const [, sessionSet] of Array.from(this.connections)) { for (const ws of Array.from(sessionSet)) { try { ws.send(JSON.stringify({ type: "shutdown" })); ws.close(1001); } catch { // ignore close errors } } } this.connections.clear(); }}
ConnectionManager maps session IDs to sets of WebSocket connections, allowing multiple clients to share a session. On shutdown it sends a shutdown event and closes all sockets cleanly.
Step 9: Register your agents with YAML
The registry loads agent definitions from YAML files in ./agents/. Create the directory and three agent configs:
The default-agent is the fallback — any intent that doesn’t match a specialized agent is routed here. The confidence_threshold determines how sure the classifier must be before routing to that agent.
Step 10: Assemble the server entry point
Create src/index.ts. This ties everything together: the Express app, the WebSocket server, the registry, the budget engine, and graceful shutdown:
ts
import express from "express";import http from "http";import cors from "cors";import { WebSocketServer, WebSocket } from "ws";import { v4 as uuidv4 } from "uuid";import { z } from "zod";import { handleInternalRequest } from "@reaatech/agent-mesh-gateway";import { initRegistry, setupSighupHandler, cleanupSighupHandler, registryState } from "@reaatech/agent-mesh-registry";import { router as gatewayRouter } from "./api/gateway/route.js";import { initBudget, flushSpendStore } from "./budget.js";import { ConnectionManager }
The server initializes the registry and budget engine on startup, attaches the gateway router at /api, mounts a /health endpoint, and sets up the WebSocket server with API key authentication. On SIGTERM or SIGINT, it closes all connections, flushes spend data to disk, and exits.
Step 11: Run the tests and start the server
The project uses Vitest with coverage thresholds of 90%. Create the vitest config first.
Expected output: Vitest loads the setup file (which mocks all @reaatech/* packages), discovers the test files, and runs them. You’ll see output similar to:
All tests should pass with green checkmarks. This validates that the gateway handles auth and rate limits correctly, the router dispatches and records spend, the handoff manager executes context transfers, and the budget engine enforces limits.
Start the development server:
terminal
pnpm run dev
Expected output:
code
[2026-01-01T00:00:00.000Z] [INFO] [server] Server started on port 3000, agents loaded: 3
Send a test request in another terminal:
terminal
curl -X POST http://localhost:3000/api/v1/request \ -H "x-api-key: dev-key" \ -H "Content-Type: application/json" \ -d '{"input":"I need help with my order","user_id":"user-1"}'
You’ll receive a structured response with request_id, session_id, agent_id, response, and workflow_complete fields.
Next steps
Add real agent YAML configs pointing to your actual LLM backends (OpenAI, Anthropic, or self-hosted models) — swap out type: mcp with real endpoint URLs and provider configuration.
Explore the WebSocket endpoint at ws://localhost:3000?api_key=dev-key&sessionId=my-session to stream real-time agent responses with the connected, processing, and result event protocol.
Adjust the budget limits and pricing table in src/budget.ts to match your actual LLM costs, and add per-agent budget scopes for finer-grained control.