Small businesses struggle to adopt multi-agent AI because orchestrating different models and services reliably—without runaway costs or fragile handoffs—is beyond their in‑house engineering capacity.
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 an Express-based agent mesh that routes incoming user requests to specialist AI agents via OpenRouter. The system classifies each request using a language model, evaluates routing confidence, checks your spending budget, dispatches to the right agent, and persists session state. By the end, you’ll have a working server with a health endpoint, a mesh entry point, and an admin registry — all wired together with observability tracking through Langfuse.
Prerequisites
Node.js >= 22
pnpm 10.x
OpenRouter API key (from openrouter.ai)
Google Cloud project with Vertex AI enabled (for the Gemini Flash classifier)
Langfuse account (self-hosted or cloud.langfuse.com) with a public/secret key pair
Basic familiarity with TypeScript and Express-style route handlers
Step 1: Initialize the project
Create a new Node.js project with TypeScript and Express. You’ll use pnpm as the package manager and configure the project as an ES module.
terminal
mkdir openrouter-agent-mesh && cd openrouter-agent-meshpnpm init -y
Expected output: A package.json is created in the current directory.
Step 2: Install dependencies
Install all required packages. The mesh uses @reaatech/agent-mesh-* packages for classification, confidence evaluation, routing, session management, and budget enforcement. Express handles HTTP routing, Langfuse handles observability, and Zod validates environment variables.
Expected output: The TypeScript compiler is configured for ESM output.
Step 4: Set up environment variables
Create a .env file based on the example. The mesh needs your OpenRouter key for agent dispatch, Google Cloud credentials for Vertex AI (which powers the Gemini Flash classifier), and Langfuse keys for observability. The registry path points to a directory where agent YAML configs live.
Expected output: If any required environment variable is missing, the app throws Environment validation failed with details.
Step 7: Create the pricing provider
Create src/lib/pricing-provider.ts. The budget engine needs a pricing provider to estimate request costs. This module defines per-model pricing for OpenRouter models.
Expected output: The pricing provider is available for the budget controller to estimate costs before dispatching requests.
Step 8: Create the spend store
Create src/lib/spend-store.ts. This persists spend records to Firestore with an in-memory fallback when Firestore is unavailable.
ts
const inMemoryStore = new Map<string, number>();function makeKey(scopeType: string, scopeKey: string): string { return `${scopeType}:${scopeKey}`;}export async function getTotalSpend(scopeType: string, scopeKey: string): Promise<number> { try { const { getFirestore } = await import("@reaatech/agent-mesh-session"); const db = getFirestore(); const snapshot = await db .collection("spend") .where("scopeType", "==", scopeType) .where("scopeKey", "==", scopeKey) .get(); let total = 0; snapshot.forEach((doc) => { const data = doc.data() as { cost: number }; total += data.cost; }); return Number(total.toFixed(6)); } catch { return inMemoryStore.get(makeKey(scopeType, scopeKey)) ?? 0; }}export async function addSpend(scopeType: string, scopeKey: string, cost: number): Promise<void> { try { const { getFirestore } = await import("@reaatech/agent-mesh-session"); const db = getFirestore(); await db.collection("spend").add({ scopeType, scopeKey, cost, timestamp: Date.now() }); } catch { const key = makeKey(scopeType, scopeKey); const current = inMemoryStore.get(key) ?? 0; inMemoryStore.set(key, Number((current + cost).toFixed(6))); }}export function resetInMemoryStore(): void { inMemoryStore.clear();}
Expected output: Spend records are written to Firestore or fall back to in-memory storage if Firestore is unavailable.
Step 9: Create the budget controller
Create src/lib/budget.ts. The budget controller wraps the @reaatech/agent-budget-engine package. It defines a per-user spending limit and checks whether a new request would exceed the cap before dispatching.
Expected output: The budget controller tracks spending per user and allows or blocks requests based on the configured limit.
Step 10: Create the registry configuration
Create src/config/mesh-config.ts. This initializes the agent registry from @reaatech/agent-mesh-registry and sets up SIGHUP-based hot-reloading so you can add agents without restarting the server.
ts
import { initRegistry, registryState, setupSighupHandler } from "@reaatech/agent-mesh-registry";export async function initializeRegistry(): Promise<void> { await initRegistry(); setupSighupHandler();}export { registryState };
Expected output: The registry is initialized at startup and can be reloaded by sending kill -HUP to the process.
Step 11: Create the classifier and confidence gate
The mesh classifies incoming text and decides whether to route or ask for clarification. Create src/mesh/classifier.ts:
ts
import { classifierService } from "@reaatech/agent-mesh-classifier";import type { ClassifierOutput } from "@reaatech/agent-mesh";export async function classifyInput( text: string, registry: Parameters<typeof classifierService.classify>[1],): Promise<ClassifierOutput> { return classifierService.classify(text, registry as never);}
Expected output: The classifier returns a structured object with agent_id, confidence, ambiguous, and detected_language.
Create src/mesh/confidence.ts:
ts
import { evaluateConfidenceGate } from "@reaatech/agent-mesh-confidence";import type { ClassifierOutput, ConfidenceDecision } from "@reaatech/agent-mesh";export function evaluateRouting( classifierOutput: ClassifierOutput, registry: Parameters<typeof evaluateConfidenceGate>[1], bypassClassifier?: boolean,): ConfidenceDecision { return evaluateConfidenceGate(classifierOutput, registry as never, bypassClassifier ?? false);}
Expected output: The confidence gate returns a decision with action (“route”, “clarify”, or “default”), the target agent_id, and optionally a clarification_question when the agent is uncertain.
Step 12: Create the router and session modules
Create src/mesh/router.ts. This uses the @reaatech/agent-mesh-router package to dispatch requests to agents via HTTP, with retries, timeouts, and Zod-based response validation.
Expected output: The dispatcher returns an AgentResponse with a content string and a workflow_complete boolean.
Create src/mesh/session.ts. This wraps the @reaatech/agent-mesh-session package to manage multi-turn conversations with Firestore persistence.
ts
import { createSession as agentCreateSession, getActiveSession, getSessionById, appendTurn, closeSession, updateWorkflowState,} from "@reaatech/agent-mesh-session";import type { SessionRecord } from "@reaatech/agent-mesh";export { getActiveSession, getSessionById, appendTurn, closeSession, updateWorkflowState,};export async function createSession(args: { userId: string; employeeId?: string; activeAgent?: string;}): Promise<SessionRecord> { return agentCreateSession({ userId: args.userId, employeeId: args.employeeId ?? args.userId, activeAgent: args.activeAgent ?? "default", });}
Expected output: Sessions are created, retrieved, and updated with turn history throughout the request lifecycle.
Step 13: Create the agent mesh core
Create src/mesh/index.ts. This is the central orchestrator that ties together classification, confidence evaluation, budget checking, dispatch, and session updates.
ts
import { classifyInput } from "./classifier.js";import { evaluateRouting } from "./confidence.js";import { dispatch } from "./router.js";import { createSession, getActiveSession, getSessionById, appendTurn, closeSession, updateWorkflowState,} from "./session.js";import { checkBudget, recordSpend } from "../lib/budget.js";import { BudgetScope } from "../lib/budget.js";import { registryState } from "../config/mesh-config.js";import { createTrace } from "../observability.js";import { logger } from "../logger.js"
Expected output: The agentMesh.process() method returns a MeshResponse with the agent’s content, workflow status, session ID, the agent that handled the request, and remaining budget.
Step 14: Create observability and registry admin modules
Create src/observability.ts. This initializes the Langfuse client and provides a trace handle for recording spans and generations throughout the request lifecycle.
Expected output: Langfuse traces are created for each request and flushed on shutdown.
Create src/mesh/registry-admin.ts. This module handles adding, updating, and removing agents from the registry via YAML content, then triggers a hot-reload.
ts
import * as fs from "node:fs/promises";import * as path from "node:path";import { AgentConfigSchema } from "@reaatech/agent-mesh";import { triggerReload } from "@reaatech/agent-mesh-registry";import { appConfig } from "../config/env.js";import { logger } from "../logger.js";function getRegistryPath(): string { return path.resolve(appConfig.AGENT_REGISTRY_PATH);}async function writeAgentFile(agentId: string, content: string): Promise<string> { const registryPath = getRegistryPath(); const filePath = path.join(registryPath, `${agentId}.yaml`); await fs.mkdir(registryPath, { recursive: true }); await fs.writeFile(filePath, content, "utf-8"); return filePath;}function parseYamlContent(content: string): Record<string, unknown> { const obj: Record<string, unknown> = {}; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const colonIdx = trimmed.indexOf(":"); if (colonIdx === -1) continue; const key = trimmed.slice(0, colonIdx).trim(); let value: unknown = trimmed.slice(colonIdx + 1).trim(); if (value === "true") value = true; else if (value === "false") value = false; else if (/^\d+\.?\d*$/.test(String(value))) value = Number(value); else if (String(value).startsWith('"') && String(value).endsWith('"')) value = String(value).slice(1, -1); else if (String(value).startsWith("'") && String(value).endsWith("'")) value = String(value).slice(1, -1); obj[key] = value; } return obj;}export async function addAgent(yamlContent: string): Promise<{ agentId: string; filePath: string }> { const parsed = parseYamlContent(yamlContent); const validated = AgentConfigSchema.parse(parsed); const agentId = validated.agent_id; if (!agentId) { throw new Error("Agent YAML must contain agent_id"); } const filePath = await writeAgentFile(agentId, yamlContent); void triggerReload(); logger.info("Agent added to registry", { agentId, filePath }); return { agentId, filePath };}export async function updateAgent(agentId: string, yamlContent: string): Promise<{ agentId: string; filePath: string }> { const parsed = parseYamlContent(yamlContent); AgentConfigSchema.parse(parsed); const filePath = await writeAgentFile(agentId, yamlContent); void triggerReload(); logger.info("Agent updated in registry", { agentId, filePath }); return { agentId, filePath };}export async function removeAgent(agentId: string): Promise<void> { const registryPath = getRegistryPath(); const filePath = path.join(registryPath, `${agentId}.yaml`); await fs.unlink(filePath); void triggerReload(); logger.info("Agent removed from registry", { agentId });}
Expected output: Agents are persisted to YAML files in AGENT_REGISTRY_PATH and hot-reloaded into the registry without a server restart.
Step 15: Create middleware
Create src/middleware/request-id.ts. This attaches a unique request ID to every incoming request, which propagates through logs and error responses.
ts
import type { Request, Response, NextFunction } from "express";export function requestIdMiddleware(req: Request, _res: Response, next: NextFunction): void { const target = req as { requestId?: string }; target.requestId = crypto.randomUUID(); next();}
Create src/middleware/session-context.ts. This middleware runs before session resolution and can be extended to attach session-scoped context to the request object.
ts
import type { Request, Response, NextFunction } from "express";export function sessionContextMiddleware(_req: Request, _res: Response, next: NextFunction): void { next();}
Create src/middleware/error-handler.ts. This catches errors thrown by route handlers and returns a JSON response with the error message and the request ID.
Expected output: Four route files handle health checks, mesh processing, admin registry operations, and Langfuse webhooks.
Step 17: Create the Express server
Create src/server.ts. This assembles the Express app with all routes and middleware.
ts
import express from "express";import type { Express } from "express";import { requestIdMiddleware } from "./middleware/request-id.js";import { sessionContextMiddleware } from "./middleware/session-context.js";import { errorHandler } from "./middleware/error-handler.js";import meshEntryRouter from "./api/mesh/entry.js";import adminRegistryRouter from "./api/admin/registry.js";import healthRouter from "./api/health.js";import langfuseWebhookRouter from "./api/webhooks/langfuse.js";export function buildServer(): Express { const app = express(); app.use(express.json()); app.use(express.text({ type: "text/yaml" })); app.use(requestIdMiddleware); app.use(sessionContextMiddleware); app.use("/api/mesh", meshEntryRouter); app.use("/admin/registry", adminRegistryRouter); app.use("/health", healthRouter); app.use("/webhooks/langfuse", langfuseWebhookRouter); app.use(errorHandler); return app;}
Expected output: The buildServer() function returns a fully wired Express app.
Step 18: Create the server entry point
Create src/index.ts. This is the process entry point that initializes the registry, starts the HTTP server, and handles graceful shutdown.
ts
import { buildServer } from "./server.js";import { initializeRegistry } from "./config/mesh-config.js";import { appConfig } from "./config/env.js";import { logger } from "./logger.js";import { shutdown as shutdownObservability } from "./observability.js";async function main(): Promise<void> { logger.info("Starting OpenRouter Agent Mesh server..."); await initializeRegistry(); logger.info("Registry initialized"); const app = buildServer(); const port = appConfig.PORT; const server = app.listen(port, () => { logger.info("Server listening on port " + String(port)); }); const handleShutdown = (signal: string) => { logger.info("Received " + signal + ", shutting down gracefully..."); server.close(() => { logger.info("HTTP server closed"); }); shutdownObservability().catch((err: unknown) => { logger.error("Observability shutdown error", { error: String(err) }); }); process.exit(0); }; process.on("SIGTERM", () => { handleShutdown("SIGTERM"); }); process.on("SIGINT", () => { handleShutdown("SIGINT"); });}main().catch((err: unknown) => { logger.error("Failed to start server", { error: String(err) }); process.exit(1);});
Expected output: Running node dist/index.js starts the server on port 3000 and logs “Server listening on port 3000”.
Step 19: Add a sample agent config
Create an agent YAML file in agents/. This defines a specialist agent that handles password-reset tasks. You can create multiple YAML files for different domains.
yaml
agent_id: "password-reset"display_name: "Password Reset Agent"description: "Handles password reset and account recovery requests"endpoint: "http://localhost:8081"type: mcpis_default: falseconfidence_threshold: 0.7clarification_required: falseexamples: - "I forgot my password" - "Reset my password please" - "Can't log into my account"
Expected output: The agent is loaded into the registry and appears in GET /admin/registry responses.
Step 20: Run the tests
The project includes vitest unit tests. Run them to verify the confidence gate, environment validation, and spend store logic.
terminal
pnpm test
Expected output: A test report showing each test file with pass/fail counts. The coverage summary includes lines, functions, branches, and paths covered.
Step 21: Start the server
terminal
node dist/index.js
Expected output: The terminal logs:
code
[timestamp] [INFO] Starting OpenRouter Agent Mesh server...
[timestamp] [INFO] Registry initialized
[timestamp] [INFO] Server listening on port 3000
Once running, send a test request:
terminal
curl -X POST http://localhost:3000/api/mesh \ -H "Content-Type: application/json" \ -d '{"text":"Reset my password","userId":"user-1"}'
Expected output: A JSON response with content, workflow_complete, sessionId, agentId, and budgetRemaining.
Add more specialist agents by creating additional YAML files in agents/ — the registry hot-reloads automatically when you send kill -HUP to the process.
Connect Firestore for durable session persistence and spend tracking across server restarts.
Wire the /webhooks/langfuse endpoint to receive Langfuse events and build dashboards that correlate cost, latency, and routing confidence for each agent.
;
import type { ClassifierOutput, AgentResponse } from "@reaatech/agent-mesh";
export interface MeshResponse {
content: string;
workflow_complete: boolean;
sessionId: string;
agentId: string;
budgetRemaining: number;
clarification_question?: string | undefined;
}
export class AgentMesh {
async process(
text: string,
sessionId?: string,
userId?: string,
): Promise<MeshResponse> {
const trace = createTrace("mesh-request");
const resolvedUserId = userId ?? "anonymous";
let currentSession: Awaited<ReturnType<typeof getActiveSession>> | Awaited<ReturnType<typeof createSession>>;