Veterinarians in small practices dread the after-hours phone. Every call or text about a limping dog or vomiting cat demands immediate judgment, but the vet is already exhausted from a full day of appointments. Without triage, they either rush to the clinic unnecessarily or risk missing a true emergency. The practice loses sleep and clients lose trust. A 24/7 triage agent that asks structured questions and assigns an urgency level would let vets rest easier and respond only when needed.
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.
After-hours phone calls are the single biggest source of burnout for small veterinary practices. Every call about a limping dog or a vomiting cat demands immediate triage judgment, but the vet is already exhausted from a full day of appointments. Without automation, they either rush to the clinic unnecessarily or risk missing a true emergency.
This tutorial builds a complete After-Hours Pet Emergency Triage Agent — a 24/7 voice-enabled triage system that uses speech-to-text, intent classification, confidence-based routing, and LLM-generated assessments to handle after-hours calls. It uses six REAA packages wired into a Next.js + Hono stack.
By the end, you’ll have a working voice triage pipeline that accepts audio input, classifies urgency, generates structured triage recommendations, and speaks them back — all tracked through observability.
Prerequisites
Node.js 22+ and pnpm 10+ installed
API keys: OpenAI, Deepgram, ElevenLabs, Twilio, and Langfuse (see .env.example)
Familiarity with TypeScript, Next.js App Router, and async/await patterns
A Twilio phone number (for production; tests run fully mocked)
Step 1: Scaffold the project
Create a Next.js 16 project with the App Router and install all dependencies.
Expected output:pnpm install completes with zero errors and pnpm-lock.yaml reflects exact-pinned versions (no ^ or ~).
Step 2: Configure environment variables
Create .env.local by copying the example. Every service the triage agent connects to — STT, TTS, LLM, telephony, and observability — reads its credentials from environment variables.
terminal
cp .env.example .env.local
Open .env.local and fill in your real API keys. Here are the variables the agent reads at runtime:
Expected output: The file exists at the project root and the .env.example stays in version control with placeholder values only — never commit real API keys.
Step 3: Create the Text-to-Speech manager (ElevenLabs)
The TTS manager wraps the ElevenLabs SDK to convert triage responses into spoken audio. It supports both on-the-fly streaming and full-response conversion.
Expected output: The file exists and compiles without type errors. The cancel() method supports barge-in — when a caller interrupts, it stops TTS playback immediately.
Step 4: Create the Speech-to-Text manager (Deepgram)
The STT manager wraps the Deepgram provider from @reaatech/voice-agent-stt. It connects to Deepgram’s live transcription API, streams mulaw audio from the phone call, and fires callbacks for utterances and end-of-speech events.
Create src/services/stt-manager.ts:
ts
import type { AudioChunk } from '@reaatech/voice-agent-core';import { DeepgramSTTProvider, type DeepgramConfig,} from '@reaatech/voice-agent-stt';export function createSTTManager() { const stt = new DeepgramSTTProvider(); const config: DeepgramConfig = { provider: 'deepgram', apiKey: process.env.DEEPGRAM_API_KEY ?? '', model: 'nova-2', language: 'en', sampleRate: 8000, encoding: 'mulaw', smartFormat: true, interimResults: true, endpointing: 300, }; async function connect() { await stt.connect(config); } function onUtterance( cb: (utterance: { transcript: string; confidence: number; isFinal: boolean; timestamp: number; }) => void, ) { stt.onUtterance(cb); } function onEndOfSpeech(cb: () => void) { stt.onEndOfSpeech(cb); } function onError(cb: (error: Error) => void) { stt.onError(cb); } function streamAudio(chunk: AudioChunk) { stt.streamAudio(chunk); } async function close() { await stt.close(); } function isConnected() { return stt.isConnected(); } function getProvider() { return stt; } return { connect, onUtterance, onEndOfSpeech, onError, streamAudio, close, isConnected, getProvider, };}
Expected output: The file compiles. Interim transcripts (isFinal: false) are forwarded but won’t trigger triage classification — only final utterances advance the pipeline.
Step 5: Create the triage classifier (agent-mesh-classifier)
The classifier maps a caller’s description to one of four triage categories: critical, urgent, routine, or advice. It wraps the classifierService singleton from @reaatech/agent-mesh-classifier, passing the triage agent registry for dynamic prompt construction.
Create src/services/triage-router.ts first — it holds the registry that both the classifier and the confidence gate share:
ts
import { evaluateConfidenceGate, generateClarificationQuestion, clarificationCache } from "@reaatech/agent-mesh-confidence";import type { AgentRegistry } from "@reaatech/agent-mesh-registry";import type { ClassifierOutput } from "@reaatech/agent-mesh";export const triageRegistry: AgentRegistry = [ { agent_id: "critical", display_name: "Critical Care", description: "Life-threatening conditions requiring immediate emergency veterinary attention — bleeding, seizure, collapse, choking, poisoning", endpoint: "mcp://triage/critical", type: "mcp", is_default: false, confidence_threshold: 0.7, clarification_required: false, examples: [ "my dog is bleeding profusely from a deep wound", "my cat is having a seizure and shaking uncontrollably", "my puppy collapsed and won't get up", ], }, { agent_id: "urgent", display_name: "Urgent Care", description: "Needs same-day veterinary attention — vomiting, limping, not eating, diarrhea, lethargy", endpoint: "mcp://triage/urgent", type: "mcp", is_default: false, confidence_threshold: 0.7, clarification_required: false, examples: [ "my dog has been vomiting all night", "my cat is limping and won't put weight on her paw", "my puppy hasn't eaten in 24 hours", ], }, { agent_id: "routine", display_name: "Routine Care", description: "Non-urgent issues that can wait for a scheduled appointment — mild rash, annual check, vaccination, nail trim", endpoint: "mcp://triage/routine", type: "mcp", is_default: false, confidence_threshold: 0.6, clarification_required: false, examples: [ "my dog has a small rash on his belly", "when is my cat due for her annual checkup", "I need to schedule a nail trim for my puppy", ], }, { agent_id: "advice", display_name: "General Advice", description: "General pet health questions — nutrition, behaviour, preventative care", endpoint: "mcp://triage/advice", type: "mcp", is_default: true, confidence_threshold: 0.6, clarification_required: false, examples: [ "what is the best diet for a senior cat", "how can I stop my dog from barking at the mailman", "what vaccinations does my puppy need", ], },];export function routeTriageDecision( classification: ClassifierOutput, bypass?: boolean,) { return evaluateConfidenceGate(classification, triageRegistry, bypass);}export { generateClarificationQuestion, clarificationCache };
Now create src/services/triage-classifier.ts:
ts
import { classifierService, detectLanguage, isRateLimitError,} from "@reaatech/agent-mesh-classifier";import { triageRegistry } from "./triage-router.js";export function createTriageClassifier() { async function classify(text: string, priorLanguage?: string) { return classifierService.classify(text, triageRegistry, priorLanguage); } function detectLang(text: string) { return detectLanguage(text); } function isMock() { return classifierService.isMock(); } function isRateLimit(err: unknown) { return isRateLimitError(err); } function getRegistry() { return triageRegistry; } return { classify, detectLanguage: detectLang, isMock, isRateLimitError: isRateLimit, getRegistry };}
Expected output: The classifier can distinguish “my dog is bleeding” (critical, high confidence) from “what’s the best diet for a senior cat” (advice, moderate confidence).
Step 6: Create the confidence-based router (agent-mesh-confidence)
The router implements the 5-rule decision tree from @reaatech/agent-mesh-confidence. It evaluates the classifier output against each agent’s threshold and returns one of three actions: route (proceed), clarify (ask for more detail), or fallback (escalate to emergency).
The triage-router.ts file you created in Step 5 already exports routeTriageDecision() which delegates to evaluateConfidenceGate(). The decision tree works as follows:
If the agent_id is unknown, it falls back to the default (advice) agent
If the classifier is already bypassed (active session), it routes directly
If confidence meets or exceeds the agent’s threshold, it routes
If clarification is enabled and confidence is below the threshold, it returns a clarification question
Otherwise it falls back with an escalation message
Expected output: A high-confidence classification like { agent_id: "critical", confidence: 0.95 } returns { action: "route" }. A low-confidence input like { agent_id: "critical", confidence: 0.45 } returns { action: "clarify" }.
Step 7: Set up the guardrail chain
The guardrail chain protects both input and output. Input guardrails redact PII, block jailbreak attempts, and enforce topic boundaries. Output guardrails scan for leaked PII and filter toxic responses.
Expected output: “contact me at john@example.com” gets its email masked. “ignore previous instructions” gets blocked by the prompt injection guardrail. “I want to buy Bitcoin” is blocked by the topic boundary.
Step 8: Build the LLM triage responder
The LLM responder uses the OpenAI SDK (configurable via OPENAI_BASE_URL so it works with any OpenAI-compatible provider) to generate a structured triage assessment. It returns a Zod-validated JSON object with urgency, summary, recommended action, and disclaimer.
Create src/services/triage-llm.ts:
ts
import OpenAI from "openai";import { z } from "zod";const TriageAssessmentSchema = z.object({ urgency: z.enum(["critical", "urgent", "routine", "advice"]), summary: z.string().max(500), recommendedAction: z.string().max(300), disclaimer: z.string(),});export type TriageAssessment = z.infer<typeof TriageAssessmentSchema>;const TRIAGE_SYSTEM_PROMPT
Expected output: A call to generateTriageResponse("my dog is bleeding profusely") returns { urgency: "critical", summary: "...", recommendedAction: "...", disclaimer: "..." }. API errors or malformed responses gracefully fall back to the “please hold for a veterinarian” message.
Step 9: Set up Langfuse observability
The observability module traces every triage turn through Langfuse. It records guardrail input/output, classifier results, confidence gate decisions, and LLM responses. When Langfuse credentials are absent, it falls back to a no-op tracer so the pipeline runs without observability.
Expected output: When LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY are set, each triage turn appears in the Langfuse dashboard with traces for guardrails, classification, and LLM generation. Without keys, it silently skips tracing.
Step 10: Create the triage MCP client (orchestrator)
The MCP client is the brains of the operation. It implements the MCPClient interface from @reaatech/voice-agent-core and ties together the guardrails, classifier, confidence gate, LLM responder, and observability into a single sendRequest() call.
Create src/services/triage-mcp-client.ts:
ts
import { z } from 'zod';import OpenAI from 'openai';import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions';import { type MCPClient, type AgentResponse,} from '@reaatech/voice-agent-core';import { classifierService } from '@reaatech/agent-mesh-classifier';import { evaluateConfidenceGate } from '@reaatech/agent-mesh-confidence';import { PIIRedaction, PromptInjection, TopicBoundary, PIIScan, ToxicityFilter,} from '@reaatech/guardrail-chain-guardrails';import { GuardrailChain } from
Expected output: A full triage turn — guardrails check input, classifier maps it, confidence gate decides, LLM generates response, output guardrails filter, and observability logs the result.
Step 11: Set up the pipeline orchestration
The pipeline factory assembles all services into the STT→LLM→TTS pipeline from @reaatech/voice-agent-core, configures latency budgets and session management, and wires pipeline events.
Create src/services/triage-pipeline.ts:
ts
import { createPipeline, createLatencyBudget, initializeSessionManager, defineConfig, LatencyBudgetEnforcer, type Pipeline, type SessionManager, type PipelineEvent, type AudioChunk,} from '@reaatech/voice-agent-core';import { DeepgramSTTProvider } from '@reaatech/voice-agent-stt';import { createTTSManager } from './tts-manager.js';import { createTriageMcpClient } from './triage-mcp-client.js';import { initObservability } from './observability.js';export interface TriagePipelineServices { pipeline: Pipeline; sessionManager: SessionManager; startSession(sessionId:
Expected output: The pipeline registers all five event handlers (pipeline:stt:final, pipeline:mcp:response, pipeline:tts:first_byte, pipeline:turn:end, pipeline:error) and exposes getters/setters for external callback wiring.
Step 12: Create the Twilio media handler
The Twilio handler wraps createTwilioHandler from @reaatech/voice-agent-telephony to manage WebSocket connections, audio send/clear, mark tracking, and barge-in detection.
Expected output: The handler connects to a WebSocket, streams audio chunks, supports barge-in, and tracks the call SID. Calling sendAudio() before acceptConnection() is a safe no-op.
Step 13: Wire up the Hono API routes and app entry
The Hono app mounts a health check and a Twilio webhook. The catch-all route handler in the Next.js App Router delegates to Hono so Twilio requests arrive correctly.
import { Hono } from "hono";import { createHealthRoute } from "./health.js";import { createTwilioWebhookRoute } from "./twilio-webhook.js";export function createTriageApp() { const app = new Hono(); app.get("/health", createHealthRoute()); app.post("/twilio/webhook", createTwilioWebhookRoute()); return app;}
Wire the Hono app into the Next.js catch-all route at app/api/[[...slug]]/route.ts:
ts
import { createTriageApp } from "../../../src/api/index.js";export const runtime = "nodejs";const honoApp = createTriageApp();export async function GET(request: Request) { const url = new URL(request.url); url.pathname = url.pathname.replace(/^\/api/, ""); const newReq = new Request(url.toString(), request); return await honoApp.fetch(newReq);}export async function POST(request: Request) { const url = new URL(request.url); url.pathname = url.pathname.replace(/^\/api/, ""); const newReq = new Request(url.toString(), request); return await honoApp.fetch(newReq);}
Update app/layout.tsx with the page metadata and global styles:
tsx
import type { Metadata } from "next";import "./globals.css";export const metadata: Metadata = { title: "After-Hours Pet Emergency Triage Agent", description: "24/7 AI-powered pet emergency triage for small veterinary practices — automate urgency assessment and reduce after-hours call burden on vets",};export const viewport = { width: 'device-width', initialScale: 1,};export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode;}>) { return ( <html lang="en"> <body>{children}</body> </html> );}
Expected output:curl http://localhost:3000/api/health returns { "status": "ok" } with HTTP 200. A POST to /api/twilio/webhook with a valid Twilio signature returns TwiML with a <Connect><Stream> block.
Step 14: Run the tests
The project includes unit tests for every service module and integration tests for the full triage flow. Run them with:
terminal
pnpm test
Expected output: All tests pass. The coverage report covers every runtime module in src/ and the API route handler.
description: 'Life-threatening pet emergencies requiring immediate veterinary attention',
endpoint: 'mcp://triage/critical',
type: 'mcp',
confidence_threshold: 0.7,
clarification_required: true,
is_default: false,
examples: [
'my dog is not breathing',
'my cat got hit by a car',
'my puppy is having seizures',
],
},
{
agent_id: 'urgent',
display_name: 'Urgent Care',
description: 'Same-day urgent pet health issues that need prompt attention',
endpoint: 'mcp://triage/urgent',
type: 'mcp',
confidence_threshold: 0.7,
clarification_required: true,
is_default: false,
examples: [
'my dog ate chocolate',
'my cat is vomiting repeatedly',
'my pet has a deep cut',
],
},
{
agent_id: 'routine',
display_name: 'Routine Care',
description: 'Non-urgent pet health questions that can wait for regular hours',
endpoint: 'mcp://triage/routine',
type: 'mcp',
confidence_threshold: 0.6,
clarification_required: false,
is_default: false,
examples: [
'my dog has a small rash',
'when should I vaccinate my kitten',
'my cat is scratching too much',
],
},
{
agent_id: 'advice',
display_name: 'General Pet Advice',
description: 'General pet health and wellness questions',
endpoint: 'mcp://triage/advice',
type: 'mcp',
confidence_threshold: 0.5,
clarification_required: false,
is_default: true,
examples: [
'what should I feed my puppy',
'how often should I walk my dog',
'is it normal for cats to sleep a lot',
],
},
];
const TRIAGE_SYSTEM_PROMPT = `You are a veterinary triage assistant. Your role is to assess pet health emergencies based on caller descriptions and provide appropriate guidance.