SMBs need multi‑step automation across email, spreadsheets, and web research but can't afford enterprise RPA and are wary of sharing sensitive client data with third‑party AI services.
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 tutorial walks you through building an Ollama Agent Mesh — an Express server that routes natural-language tasks to local LLM specialist agents. You’ll set up an intent classifier, confidence-based routing, session continuity across turns, and adapters for Google Sheets, spreadsheet generation, and web research. Everything runs on your own hardware through Ollama, so no customer data ever leaves your network.
Prerequisites
Node.js >= 22 and pnpm 10 installed locally
Ollama running on http://127.0.0.1:11434 with at least one pulled model:
terminal
ollama pull llama3.1ollama pull qwen3:1.8b
Basic familiarity with TypeScript, Express, and REST APIs
A terminal ready to copy-paste commands
Step 1: Create the configuration file
The entire mesh is driven by a single YAML file at the project root. Create mesh-config.yaml:
yaml
confidence: routeThreshold: 0.8 fallbackThreshold: 0.3llm: defaultStrategy: cost-optimized models: - id: llama3.1 capabilities: [general, code, reasoning] costPerMillionInput: 0 costPerMillionOutput: 0 maxTokens: 8192 - id: qwen3:1.8b capabilities: [general, analysis] costPerMillionInput: 0 costPerMillionOutput: 0 maxTokens: 32768agents: - id: email-triage displayName: Email Triage Specialist model: qwen3:1.8b instructions: > You are an email triage specialist for a small business. Categorize each email as: support-request, sales-inquiry, billing-issue, or spam. Extract sender name, email, and key action items. Draft a reply when possible. - id: crm-update displayName: CRM Update Specialist model: llama3.1 instructions: > You extract structured CRM records from conversation. Output a JSON array of records with fields: company, contact_name, email, phone, deal_stage, notes, next_step. Only include verified facts, mark uncertain data with confidence. - id: report displayName: Report Generation Specialist model: llama3.1 instructions: > You generate structured report data. For each report request, produce a JSON array of row objects with consistent keys. Include summary statistics when relevant. Output should be ready for spreadsheet conversion.session: maxTokens: 4096 reserveTokens: 500 overflowStrategy: compress
This file defines three specialist agents, model routing rules, confidence thresholds, and session token budgets. The mesh will read it at startup.
Step 2: Set up environment variables
Create .env from the example template:
terminal
cp .env.example .env
The .env.example file already contains these placeholders:
At minimum, ensure OLLAMA_HOST=http://127.0.0.1:11434 is correct for your local Ollama instance. The server loads these at startup via import 'dotenv/config'.
Step 3: Define the TypeScript types for your config
Create src/config/config-types.ts to type the YAML structure:
These interfaces are the contract between your YAML and every module in the mesh.
Step 4: Build the config loader
Create src/config/config-loader.ts — it reads the YAML, validates the llm section against @reaatech/llm-router-core’s RouterConfigSchema, and fills in defaults for any missing sections.
ts
import { readFileSync } from 'node:fs';import { load } from 'js-yaml';import { RouterConfigSchema } from '@reaatech/llm-router-core';import { type AgentConfig, AgentConfigSchema } from '@reaatech/agent-mesh';import { type MeshConfig, type AgentRegistration, type LlmModelConfig, type ConfidenceConfig, type SessionConfig,} from './config-types.js';export class ConfigError extends Error { readonly code: string; readonly path?: string;
Two key exports: loadMeshConfig() which reads and parses your YAML, and configToAgentConfig() which maps the camelCase AgentRegistration to the snake_case AgentConfig expected by @reaatech/agent-mesh.
Expected output: a loader that throws ConfigError (code PARSE_ERROR) on missing/malformed files and ConfigError (code VALIDATION_ERROR) on invalid llm sections.
Step 5: Create the Ollama client adapter
The src/adapters/ollama-adapter.ts wraps the ollama npm package so the rest of your mesh can chat with local models:
The chatCompletion function is the primary way the mesh talks to a local LLM. It returns the content string plus a rough total token count from Ollama’s response metadata.
Expected output: an adapter that calls ollama.chat() and returns { content, totalTokens }. When Ollama is unreachable, it throws OllamaError with code CHAT_FAILED.
Step 6: Build a token counter for session continuity
The session-continuity package needs a TokenCounter to enforce token budgets. Create src/lib/token-counter.ts with a simple estimate (4 characters per token):
ts
import { type TokenCounter, type Message } from '@reaatech/session-continuity';export class SimpleTokenCounter implements TokenCounter { readonly model: string; readonly tokenizer = 'simple-estimate'; constructor(modelName = 'default') { this.model = modelName; } count(text: string): number { return Math.ceil(text.length / 4); } countTokens(content: string | unknown[]): number { if (typeof content === 'string') { return Math.ceil(content.length / 4); } return Math.ceil(JSON.stringify(content).length / 4); } countMessages(messages: Message[]): number { let total = 0; for (const msg of messages) { const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); total += Math.ceil(text.length / 4); } return total; }}export function createTokenCounter(modelName?: string): SimpleTokenCounter { return new SimpleTokenCounter(modelName ?? 'default');}
This counter implements the TokenCounter interface required by session-continuity. Since local Ollama models don’t share a tokenizer, the 4-char heuristic is a reasonable approximation.
Expected output:SimpleTokenCounter that returns Math.ceil(text.length / 4) for any string, 0 for empty strings, and 2500 for a 10,000-character input.
Step 7: Define the specialist agents
Each specialist agent is a Mastra Agent backed by an Ollama model via @ai-sdk/openai-compatible. Create three agent configuration files.
src/agents/email-triage-agent.ts:
ts
import { type AgentRegistration } from '../config/config-types.js';export const emailTriageConfig: AgentRegistration = { id: 'email-triage', displayName: 'Email Triage Specialist', model: 'qwen3:1.8b', instructions: 'You are an email triage specialist. Categorize incoming emails into one of: support-request, sales-inquiry, billing-issue, or spam. ' + 'Analyze the email content, subject line, and sender information to determine the correct category. ' + 'Extract sender name, email, and key action items. Draft a reply when possible.',};
src/agents/crm-update-agent.ts:
ts
import { type AgentRegistration } from '../config/config-types.js';export const crmUpdateConfig: AgentRegistration = { id: 'crm-update', displayName: 'CRM Update Specialist', model: 'llama3.1', instructions: 'You are a CRM data entry specialist. Extract structured CRM records from the provided text or email. ' + 'Output a JSON array of records with fields: company, contact_name, email, phone, deal_stage, notes, next_step. ' + 'Only include verified facts, mark uncertain data with confidence.',};
src/agents/report-agent.ts:
ts
import { type AgentRegistration } from '../config/config-types.js';export const reportConfig: AgentRegistration = { id: 'report', displayName: 'Report Generation Specialist', model: 'llama3.1', instructions: 'You are a reporting specialist. Generate structured report data as JSON arrays from the provided input. ' + 'produce a JSON array of row objects with consistent keys. Include summary statistics when relevant.',};
Expected output: three files, each exporting an AgentRegistration object with an id, display name, Ollama model, and system instructions.
Step 8: Create the Mastra-compatible Ollama provider
The src/agents/specialist-agent.ts bridges Mastra and Ollama:
ts
import { Agent } from '@mastra/core/agent';import { createOpenAICompatible, type OpenAICompatibleProvider } from '@ai-sdk/openai-compatible';import { type AgentRegistration } from '../config/config-types.js';export function createOllamaProvider(): OpenAICompatibleProvider { const baseURL = process.env.OLLAMA_HOST ?? 'http://localhost:11434'; return createOpenAICompatible({ name: 'ollama', baseURL: `${baseURL}/v1` });}export function createSpecialistAgent( config: AgentRegistration, provider: ReturnType<typeof createOllamaProvider>,) { return new Agent({ id: config.id, name: config.displayName, instructions: config.instructions, model: provider.chatModel(config.model), });}
Ollama exposes an OpenAI-compatible endpoint at /v1, so createOpenAICompatible() from @ai-sdk/openai-compatible works directly. Each specialist agent is a Mastra Agent with a local model.
Expected output: a provider factory and agent factory that produce Mastra Agent instances connected to your local Ollama models.
Step 9: Build the agent registry
The src/agents/agent-registry.ts holds the active specialist agents and initializes them from your config:
ts
import { Agent } from '@mastra/core/agent';import { type AgentRegistration, type MeshConfig } from '../config/config-types.js';import { createSpecialistAgent, createOllamaProvider } from './specialist-agent.js';export class AgentRegistry { private agents: Map<string, Agent> = new Map(); private agentConfigs: Map<string, AgentRegistration> = new Map(); register(id: string, agent: Agent, registration?: AgentRegistration): void { this.agents.set(id, agent); if (registration) { this.agentConfigs.set(id, registration); } } get(id: string): Agent | undefined { return this.agents.get(id); } list(): AgentRegistration[] { return Array.from(this.agentConfigs.values()); } initFromConfig(cfg: MeshConfig): void { const provider = createOllamaProvider(); for (const agentCfg of cfg.agents) { const agent = createSpecialistAgent(agentCfg, provider); this.register(agentCfg.id, agent, agentCfg); } }}
initFromConfig() is the main entry point — it creates every agent listed in your YAML and stores them by id for later lookup.
Expected output: a registry where register() and get() round-trip correctly, get() with an unknown id returns undefined, and initFromConfig() registers all agents from your MeshConfig.
Step 10: Add the confidence router
The @reaatech/confidence-router decides whether to route to a specialist, ask for clarification, or fall back to a general model. Create src/lib/confidence-router.ts:
ts
import { ConfidenceRouter, RouterFactory, type RoutingDecision,} from '@reaatech/confidence-router';import { type ClassifierOutput } from '@reaatech/agent-mesh';import { type ConfidenceConfig } from '../config/config-types.js';export function createMeshConfidenceRouter(cfg: ConfidenceConfig): ConfidenceRouter { return RouterFactory.create({ routeThreshold: cfg.routeThreshold, fallbackThreshold: cfg.fallbackThreshold, clarificationEnabled: true, });}export function classifyOutputToDecideInput( co: ClassifierOutput,): { predictions: Array<{ label: string; confidence: number }> } { return { predictions: [ { label: co.agent_id, confidence: co.confidence, }, ], };}export function classifyAndDecide( router: ConfidenceRouter, classification: ClassifierOutput,): RoutingDecision { const input = classifyOutputToDecideInput(classification); return router.decide(input);}
The confidence router maps the intent classifier’s output (agent_id, confidence) into the decide() format and returns a RoutingDecision — one of ROUTE, CLARIFY, or FALLBACK.
Expected output: a router where high confidence (above routeThreshold: 0.8) returns type: 'ROUTE', mid confidence returns type: 'CLARIFY' with a prompt, and low confidence (below fallbackThreshold: 0.3) returns type: 'FALLBACK'.
Step 11: Wire up session continuity
The src/lib/session-manager.ts wraps @reaatech/session-continuity with an in-memory storage adapter and your token counter:
ts
import { SessionManager, type IStorageAdapter, type Session, type Message, SessionNotFoundError, ConcurrencyError, type SessionId, type MessageId, type SessionFilters, type MessageQueryOptions, type UpdateSessionOptions, type HealthStatus,} from '@reaatech/session-continuity';import { createTokenCounter } from './token-counter.js';import { type SessionConfig } from '../config/config-types.js';import { randomUUID } from 'node:crypto';export class InMemoryStorageAdapter implements
The InMemoryStorageAdapter implements all 12 methods of the IStorageAdapter interface. createSessionManager() wires it together with the token counter and compression configuration. getOrCreateSession() is the higher-level helper the orchestrator uses.
Expected output: a session manager where createSession returns a session with status: 'active', getSession with an unknown id throws SessionNotFoundError, deleteMessage with an unknown session also throws SessionNotFoundError, and concurrent updates with wrong expectedVersion throw ConcurrencyError.
Step 12: Build the handoff manager
Agent handoff is handled by src/lib/handoff-manager.ts using @reaatech/agent-handoff with exponential backoff retry:
The handoff manager retries TransportError and TimeoutError with exponential backoff but immediately rethrows RoutingError (no point retrying a routing failure).
Expected output:execute() returns { success: true } when agents are available, throws TransportError after 3 retries when unreachable, and rethrows RoutingError immediately.
Step 13: Build the intent classifier
The src/services/intent-classifier.ts is the bridge between incoming requests and the agent mesh. It calls dispatchToAgent from @reaatech/agent-mesh-router and converts session messages into the TurnEntry[] format that the mesh expects:
sessionTurnHistoryToTurnEntries() converts session-continuity Message[] objects into TurnEntry[] objects that @reaatech/agent-mesh-router’s dispatchToAgent requires, maintaining the role, content, and timestamp across turns.
Expected output:classifyIntent returns a parsed ClassifierOutput, routeToSpecialist dispatches with turnHistory populated from session messages, and buildClassifierAgentConfig returns a valid AgentConfig.
Step 14: Wire the external service adapters
The mesh integrates with three external services through adapters in src/adapters/.
Expected output: three adapter modules — CRM (Google Sheets append), spreadsheet (xlsx report generation), and web research (Firecrawl scrape). Each has custom error types matching its failure modes.
Step 15: Build the mesh orchestrator
The src/services/mesh-orchestrator.ts ties everything together. It receives incoming requests, classifies intent, decides routing, dispatches to the specialist agent, and handles post-processing (CRM updates, report generation, session management).
ts
import { IncomingRequestSchema, type AgentResponse } from '@reaatech/agent-mesh';import { mcpClientFactory } from '@reaatech/agent-mesh-router';import { type SessionManager } from '@reaatech/session-continuity';import { type ConfidenceRouter } from '@reaatech/confidence-router';import { type Firecrawl } from '@mendable/firecrawl-js';import Langfuse from 'langfuse';import { type MeshConfig } from '../config/config-types.js';import { configToAgentConfig } from '../config/config-loader.js';import { AgentRegistry } from '../agents/agent-registry.js';
The orchestrator’s executeTask method follows this flow: validate input → get or create session → add user message to session → classify intent → decide routing → branch on ROUTE/CLARIFY/FALLBACK. On ROUTE, it also handles CRM updates and report generation as side effects.
Expected output: a fully wired orchestrator where a valid { input: "help with email", employee_id: "emp-1" } request flows through classification → confidence routing → specialist dispatch → response back to the caller.
Step 16: Create the Express server
The src/server.ts wires all the components together and exposes the HTTP endpoints:
ts
import 'dotenv/config';import express, { type Request, type Response, type NextFunction } from 'express';import cors from 'cors';import { loadMeshConfig } from './config/config-loader.js';import { createSessionManager } from './lib/session-manager.js';import { createMeshConfidenceRouter } from './lib/confidence-router.js';import { HandoffManager } from './lib/handoff-manager.js';import { AgentRegistry } from './agents/agent-registry.js';import { createFirecrawlClient } from './adapters/web-research-adapter.js';import { MeshOrchestrator } from './services/mesh-orchestrator.js';import { initTracing } from './observability/tracer.js';import { MeshError } from './lib/errors.js';const config = loadMeshConfig();const sessionManager = createSessionManager(config.session);const confidenceRouter = createMeshConfidenceRouter(config.confidence);const handoff = new HandoffManager();const registry = new AgentRegistry();registry.initFromConfig(config);const langfuse = initTracing();const firecrawl = createFirecrawlClient();const orchestrator = new MeshOrchestrator({ config, registry, confidenceRouter, sessionManager, handoff, firecrawl, langfuse,});const app = express();app.use(express.json({ limit: '1mb' }));app.use(cors());app.post('/execute-task', async (req: Request, res: Response, next: NextFunction) => { try { const result = await orchestrator.executeTask(req.body); res.json(result); } catch (err) { next(err); }});app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', uptime_ms: process.uptime() * 1000 });});app.use((err: unknown, _req: Request, res: Response, _next: NextFunction): void => { void _next; const message = err instanceof Error ? err.message : String(err); const code = err instanceof MeshError ? err.code : 'INTERNAL_ERROR'; const status = err instanceof MeshError ? 400 : 500; res.status(status).json({ error: message, code });});const port = Number(process.env.PORT ?? 3001);app.listen(port, () => { console.log(`Mesh server listening on port ${String(port)}`);});async function handleShutdown(): Promise<void> { await orchestrator.shutdown(); process.exit(0);}process.on('SIGTERM', () => { void handleShutdown(); });process.on('SIGINT', () => { void handleShutdown(); });
Expected output:POST /execute-task returns 200 with agent response on success, 400 for MeshError (validation failures), and 500 for unhandled errors. GET /health returns { status: 'ok', uptime_ms: ... }.
Step 17: Run the tests
The project includes a full test suite with mocks for every external dependency. Run the test script to verify everything is wired correctly:
terminal
pnpm test
Expected output:numFailedTests: 0 and coverage thresholds of at least 90% on lines, branches, functions, and statements.
You can also verify type safety and lint rules:
terminal
pnpm typecheckpnpm lint
Both should exit zero — no : any, as unknown as, @ts-ignore, @ts-expect-error, or eslint-disable directives in source or test files.
Next steps
Add more specialist agents — Register new agent types in mesh-config.yaml for tasks like compliance checking, invoice processing, or scheduling. The generic AgentRegistry.initFromConfig() picks them up automatically.
Replace in-memory storage — Swap InMemoryStorageAdapter for a Redis or SQLite implementation of IStorageAdapter to support multi-instance deployments and session persistence across restarts.
Wire in Langfuse observability — Set the LANGFUSE_* environment variables to start tracing every classify, route, and agent dispatch step. The traceStep() calls are already in the orchestrator — they just need a backend.