Skip to content
/ solutions / vertex-ai-voice-agent-for-field-service-dispatch Vertex AI Voice Agent for Field Service Dispatch Automate incoming service calls, classify needs, and route to the right team with human-like voice interaction powered by Vertex AI.
The problem Small field service businesses (plumbing, HVAC, electrical) miss after-hours calls, lose revenue, and struggle to triage service requests efficiently, leading to customer churn.
Example artifact 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.
86 tests · 94.6% coverage · vitest passing
Comments Sign in to commentSign in with GitHub to comment and vote.
© 2026 REAA Technologies Inc. MIT-licensed open-source packages. No vendor lock-in. No bullshit.
On this page Intro
You’ll build a real-time voice agent that answers field service phone calls, classifies the caller’s needs with Vertex AI Gemini, and routes the call to the right team — emergency repair, maintenance, or estimates. By the end, you’ll have a working Next.js application that receives Twilio webhooks, creates LiveKit rooms for voice sessions, runs speech-to-text through Deepgram and text-to-speech through ElevenLabs, tracks your LLM spending with a budget engine, and serves a dashboard that shows live call logs and cost data.
Prerequisites
Node.js >= 22 and pnpm 10.14.0 — the exact version pinned in package.json
A Twilio account with a voice-enabled phone number — you’ll need your Account SID and auth token
A LiveKit server (self-hosted or LiveKit Cloud) — you’ll need an API key, secret, and WebSocket URL
A Google Cloud project with Vertex AI enabled — you’ll need the project ID and a location like us-central1
A Deepgram API key for real-time speech-to-text
An ElevenLabs API key for text-to-speech
A Firebase project with Firestore enabled — you’ll need a service account key JSON file
Familiarity with TypeScript and Next.js App Router routing
Step 1: Scaffold the project
Create an empty directory and initialize a Next.js TypeScript project with all the dependencies the voice agent needs.
mkdir voice-agent && cd voice-agent
pnpm init
Set the package type and engine requirements. Create package.json with the exact versions from the artifact:
{
"name" : "vertex-ai-voice-agent-for-field-service-dispatch" ,
"version" : "1.0.0" ,
"private" : true ,
"type" : "module" ,
"engines"
Install everything and add the TypeScript config:
Create tsconfig.json:
{
"compilerOptions" : {
"strict" : true ,
"esModuleInterop" : true ,
"moduleResolution" : "bundler" ,
"target" : "ES2022" ,
"module" : "ESNext" ,
"jsx" : "react-jsx" ,
"skipLibCheck" : true ,
"forceConsistentCasingInFileNames" : true ,
"resolveJsonModule" : true ,
"isolatedModules" : true ,
"noUncheckedIndexedAccess"
Create vitest.config.ts for the test runner:
import { defineConfig } from 'vitest/config' ;
import path from 'path' ;
// Custom reporter that merges coverage-summary into vitest-report.json
const coverageMergingReporter = {
onFinished : async (_files : unknown [], _errors : unknown [], _coverage : unknown )
Expected output: pnpm install downloads all packages. The src directory doesn’t exist yet — you’ll create it as you add files in the following steps.
Step 2: Set environment variables
Create .env.local at the project root. Every variable here is read by the application at runtime. Fill in your own values for the placeholders:
# Twilio
TWILIO_ACCOUNT_SID = ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN = your_twilio_auth_token
TWILIO_PHONE_NUMBER = +15551234567
# LiveKit
LIVEKIT_API_KEY = your_livekit_api_key
LIVEKIT_API_SECRET = your_livekit_api_secret
LIVEKIT_URL = wss://your-livekit-server.com
# Google Cloud Vertex AI
GOOGLE_CLOUD_PROJECT = your-gcp-project-id
GOOGLE_CLOUD_LOCATION = us-central1
GOOGLE_GENAI_USE_VERTEXAI = true
# Deepgram (speech-to-text)
Step 3: Handle incoming calls with the Twilio webhook
When a customer calls your Twilio phone number, Twilio sends a POST request to your /api/calls endpoint. This route validates the request, creates a LiveKit room for the voice session, calls the dispatcher to classify and route, and returns TwiML that connects the caller.
Create the directories and the route file:
Create src/api/calls/route.ts:
This route does several things: validates Twilio’s cryptographic signature (preventing spoofed webhooks), creates a LiveKit room named after the call SID, generates an access token for the room, dispatches the call for classification, and returns a <Connect><Sip> TwiML response that bridges the PSTN call into the LiveKit room.
Step 4: Classify and route with the agent dispatcher
The dispatcher is the brain of the voice agent. It uses Vertex AI Gemini to classify the caller’s transcript into one of three categories (emergency, maintenance, estimate), retrieves customer history from Agent Memory, selects the best-fit agent using CapabilityBasedRouter, and persists the session via Agent Mesh Session.
Create the agents directory and the dispatcher file:
Create src/agents/dispatcher.ts. This file is ~300 lines; the excerpt below shows the classification, routing, and session management logic, plus the public dispatchCall and endCall exports. The full file (including type definitions, helper functions, and the complete error handling) is in the downloadable artifact.
The dispatcher registers three agents — Emergency Repair, Maintenance, and Estimate — each with skills mapped to the classification categories. It uses the CapabilityBasedRouter to score and select the best agent match. If classification or routing fails, the system falls back gracefully to the maintenance agent so no call goes unanswered.
Step 5: Build the voice pipeline
The voice pipeline wires together the three services that process live audio: Deepgram for speech-to-text (STT), Vertex AI Gemini for response generation, and ElevenLabs for text-to-speech (TTS). It also checks the budget engine before each LLM call and records usage afterward.
Create src/agents/pipeline.ts:
The pipeline connects to Deepgram’s WebSocket for real-time streaming STT using the Nova-3 model, builds a conversation context from past turns, and generates responses with Gemini 1.5 Pro. When the budget is tight, it automatically downgrades to a cheaper model. Every successful LLM call records cost data through the budget engine.
Step 6: Track spending with the budget engine
The budget engine enforces spending limits so your voice agent doesn’t surprise you with a massive bill. It defines Vertex AI pricing for Gemini models, persists spend records to Firestore with atomic transactions, and checks each LLM request against your monthly budget — downgrading models or blocking requests when thresholds are hit.
Create the library directory and two files:
Create src/lib/firestore-admin.ts — a small helper that initializes the Firebase Admin SDK once and provides a Firestore client:
import { initializeApp, getApps } from 'firebase-admin/app' ;
import { getFirestore, Firestore } from 'firebase-admin/firestore' ;
let db : Firestore | null = null ;
export function getFirestoreDb () : Firestore {
if (db) return db;
if ( ! getApps ().length) {
initializeApp ({ projectId: process.env.GOOGLE_CLOUD_PROJECT ?? 'default' });
}
db = getFirestore ();
Create src/lib/budget.ts. This file is ~220 lines; the excerpt below shows the pricing, controller setup, checkRequest, and recordUsage functions:
The budget policy defines a soft cap at 80% (warnings) and a hard cap at 100% (blocking). The auto-downgrade rule swaps gemini-1.5-pro for the cheaper gemini-1.5-flash when spend nears the limit. All spend records are persisted to Firestore with atomic transactions so concurrent requests can’t double-count.
Step 7: Add health monitoring
Your voice agent depends on five external services: Twilio, LiveKit, Vertex AI, Deepgram, and ElevenLabs. The health check system probes each one with timeouts and reports an aggregate status (healthy, degraded, or unhealthy). When more than two services fail, it logs a structured alert.
Create src/health.ts:
Now expose the checks at a route. Create src/api/health/route.ts:
import { NextRequest, NextResponse } from 'next/server' ;
import { runHealthChecks } from '../../health' ;
import type { HealthCheckResult } from '../../health' ;
export async function GET (_request : NextRequest ) : Promise < NextResponse > {
const result : HealthCheckResult = await runHealthChecks ();
let httpStatus : number ;
if (result.status ===
You can verify the health endpoint by visiting http://localhost:3000/api/health. A fully configured deployment returns HTTP 200 with JSON like:
{
"status" : "healthy" ,
"timestamp" : "2026-05-12T17:00:00.000Z" ,
"checks" : [
{ "service" : "Twilio" , "status" : "pass" , "latencyMs" : 120 },
{ "service" : "LiveKit" , "status" : "pass" , "latencyMs" : 85 },
{ "service" : "VertexAI" , "status" : "pass" , "latencyMs" : 340 },
{
Step 8: Build the operator dashboard
The dashboard at /dashboard shows a real-time budget overview and a table of recent call logs. It’s protected by an API key that must be passed as the x-api-key header.
Create the dashboard directory structure:
mkdir -p src/app/dashboard
Add middleware to protect dashboard routes. Create src/middleware.ts:
import { NextRequest, NextResponse } from 'next/server' ;
export function middleware (request : NextRequest ) : NextResponse {
const pathname = request.nextUrl.pathname;
if ( ! pathname. startsWith ( '/dashboard' )) {
return NextResponse. next ();
}
const apiKey = request.headers. get ( 'x-api-key' );
const
Create the dashboard layout at src/app/dashboard/layout.tsx:
interface DashboardLayoutProps {
children : React . ReactNode ;
}
export default function DashboardLayout ({ children } : DashboardLayoutProps ) {
return (
<div className = "dashboard-layout" >
<header className = "dashboard-header" >
<h1>Field Service Dispatch</h1>
</header>
<main className = "dashboard-main" >{ children }</main>
The dashboard page at src/app/dashboard/page.tsx composes two client components — a budget card and a call log table — each wrapped in an error boundary:
import CallLogTable from '@/components/CallLogTable' ;
import BudgetCard from '@/components/BudgetCard' ;
import ErrorBoundary from '@/components/ErrorBoundary' ;
export default function DashboardPage () {
return (
<div className = "dashboard-content" >
<section className = "dashboard-section budget-section" >
<h2>Budget Overview</h2>
< ErrorBoundary >
Create src/components/ErrorBoundary.tsx — a class component that catches render errors and shows a retry button:
'use client' ;
import { Component, ErrorInfo, ReactNode } from 'react' ;
interface ErrorBoundaryProps {
children : ReactNode ;
fallback ?: ReactNode
Finally, create the two dashboard components. The BudgetCard fetches /api/budget and renders a progress bar with cost breakdown by spend tier color. The CallLogTable fetches /api/calls/logs and renders a table with status badges. Both handle loading, error, and empty states.
Create src/components/BudgetCard.tsx:
Create src/components/CallLogTable.tsx:
With all dashboard files in place, start the dev server and visit http://localhost:3000/dashboard with the x-api-key header set to your DASHBOARD_API_KEY value. You’ll see the budget overview card and the call log table.
Step 9: Run the tests
The artifact includes a full test suite using Vitest with React Testing Library and MSW for mocking HTTP requests. All external services are mocked — no live API calls happen during tests.
Create the test setup file at src/test-setup.ts to configure environment stubs and the MSW server:
import '@testing-library/jest-dom/vitest' ;
import { setupServer } from 'msw/node' ;
import { beforeAll, afterAll, afterEach, vi } from 'vitest' ;
import { handlers } from './mocks/handlers' ;
vi. stubEnv ( 'TWILIO_ACCOUNT_SID' , 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
vi. stubEnv ( 'TWILIO_AUTH_TOKEN' , '' );
vi. stubEnv ( 'LIVEKIT_URL'
The MSW handlers and test data factories are in src/mocks/. The full implementations are in the downloadable artifact.
Run the test suite:
Expected output:
✓ src/api/calls/route.test.ts (9 tests)
✓ src/api/health/route.test.ts (8 tests)
✓ src/agents/dispatcher.test.ts (9 tests)
✓ src/agents/pipeline.test.ts (12 tests)
✓ src/components/__tests__/BudgetCard.test.tsx (9 tests)
✓ src/components/__tests__/CallLogTable.test.tsx (8 tests)
✓ src/integration.test.ts (9 tests)
✓ src/lib/budget.test.ts (9 tests)
Test Files 8 passed (8)
Tests 73 passed (73)
To run with coverage (which enforces 90% thresholds on lines, branches, functions, and statements):
When all tests pass, start the dev server to see the application running:
Then configure your Twilio phone number’s voice webhook URL to https://your-domain.com/api/calls. Incoming calls will now trigger the full pipeline: Twilio webhook → LiveKit room creation → Vertex AI Gemini classification → agent routing → Deepgram STT → Gemini response generation → ElevenLabs TTS.
Next steps
Connect the dashboard API routes (/api/budget and /api/calls/logs) by wiring the budget controller’s getRemainingBudget and the Firestore getCallLogs function into Next.js route handlers — the budget engine and Firestore client are already written, they just need route files to expose them.
Deploy to a platform like Vercel or Cloud Run and configure your Twilio phone number’s voice webhook to point at the production URL.
Extend the dispatcher agent registry with additional agent types — for example, a Spanish-speaking agent or an after-hours on-call agent with different routing rules.
: {
"node" : ">=22"
},
"packageManager" : "pnpm@10.14.0" ,
"scripts" : {
"dev" : "next dev" ,
"build" : "next build" ,
"start" : "next start" ,
"typecheck" : "tsc --noEmit" ,
"lint" : "eslint ." ,
"test" : "vitest run" ,
"test:coverage" : "vitest run --coverage --reporter=json --outputFile=vitest-report.json && node -e 'const r=require(\"./vitest-report.json\"),c=require(\"./coverage/coverage-summary.json\");r.coverage=c;require(\"fs\").writeFileSync(\"./vitest-report.json\",JSON.stringify(r))'"
},
"dependencies" : {
"@deepgram/sdk" : "5.2.0" ,
"@elevenlabs/elevenlabs-js" : "2.46.0" ,
"@google-cloud/vertexai" : "1.12.0" ,
"@livekit/agents-plugin-google" : "1.4.0" ,
"@livekit/protocol" : "1.31.0" ,
"@reaatech/agent-budget-engine" : "0.1.0" ,
"@reaatech/agent-budget-spend-tracker" : "0.1.0" ,
"@reaatech/agent-budget-types" : "0.1.0" ,
"@reaatech/agent-handoff" : "0.1.0" ,
"@reaatech/agent-handoff-routing" : "0.1.0" ,
"@reaatech/agent-memory" : "0.1.0" ,
"@reaatech/agent-mesh" : "1.0.0" ,
"@reaatech/agent-mesh-session" : "1.0.0" ,
"@reaatech/agent-runbook" : "0.1.0" ,
"@reaatech/agent-runbook-health-checks" : "0.1.0" ,
"firebase-admin" : "12.7.0" ,
"livekit-server-sdk" : "2.10.0" ,
"next" : "14.2.15" ,
"react" : "18.3.1" ,
"react-dom" : "18.3.1" ,
"twilio" : "6.0.2"
},
"devDependencies" : {
"@testing-library/jest-dom" : "6.6.3" ,
"@testing-library/react" : "16.0.1" ,
"@testing-library/user-event" : "14.5.2" ,
"@types/node" : "22.9.0" ,
"@types/react" : "18.3.12" ,
"@typescript-eslint/eslint-plugin" : "8.14.0" ,
"@typescript-eslint/parser" : "8.14.0" ,
"@vitest/coverage-v8" : "2.1.4" ,
"eslint" : "9.14.0" ,
"eslint-plugin-vitest" : "0.5.4" ,
"jsdom" : "25.0.1" ,
"msw" : "2.6.5" ,
"typescript" : "5.6.3" ,
"vitest" : "2.1.4"
}
}
:
true
,
"paths" : {
"@/*" : [ "./src/*" ]
}
},
"include" : [ "src" , "tests" , "*.ts" , "*.mts" ],
"exclude" : [ "node_modules" , ".next" , "dist" , "coverage" ]
}
=>
{
const { readFileSync, writeFileSync, existsSync } = await import ( 'fs' );
const { join } = await import ( 'path' );
const cwd = process. cwd ();
const reportPath = join (cwd, 'vitest-report.json' );
const summaryPath = join (cwd, 'coverage/coverage-summary.json' );
if ( existsSync (reportPath) && existsSync (summaryPath)) {
try {
const report = JSON. parse ( readFileSync (reportPath, 'utf8' ));
const summary = JSON. parse ( readFileSync (summaryPath, 'utf8' ));
report.coverage = summary;
writeFileSync (reportPath, JSON. stringify (report));
} catch {
// Non-fatal
}
}
},
};
export default defineConfig ({
test: {
include: [ 'src/**/*.test.ts' , 'src/**/*.test.tsx' ],
environment: 'jsdom' ,
setupFiles: [ './src/test-setup.ts' ],
reporters: [coverageMergingReporter],
coverage: {
provider: 'v8' ,
reporter: [ 'text' , 'html' , 'json' , 'json-summary' ],
thresholds: {
lines: 90 ,
branches: 90 ,
functions: 90 ,
statements: 90 ,
autoUpdate: false ,
},
},
},
resolve: {
alias: {
'@' : path. resolve (__dirname, './src' ),
},
},
});
DEEPGRAM_API_KEY
=
your_deepgram_api_key
# ElevenLabs (text-to-speech)
ELEVENLABS_API_KEY = your_elevenlabs_api_key
# Budget
BUDGET_MONTHLY_LIMIT_USD = 50.0
# Firebase
FIREBASE_SERVICE_ACCOUNT_PATH = /path/to/your-service-account-key.json
# Dashboard access
DASHBOARD_API_KEY = your-dashboard-api-key
import { NextRequest, NextResponse } from 'next/server' ;
import { RoomServiceClient, AccessToken } from 'livekit-server-sdk' ;
import { dispatchCall } from '../../agents/dispatcher' ;
import * as crypto from 'node:crypto' ;
function buildTwiML (content : string ) : string {
return `<?xml version="1.0" encoding="UTF-8"?><Response>${ content }</Response>` ;
}
function validateTwilioSignature (
authToken : string ,
signatureHeader : string ,
url : string ,
params : Record < string , string >,
) : boolean {
const sortedKeys = Object. keys (params). sort ();
let validationString = url;
for ( const key of sortedKeys) {
validationString += key + params[key];
}
const computedSignature = crypto
. createHmac ( 'sha1' , authToken)
. update (validationString)
. digest ( 'base64' );
if (computedSignature.length !== signatureHeader.length) {
return false ;
}
return crypto. timingSafeEqual (
Buffer. from (computedSignature),
Buffer. from (signatureHeader),
);
}
export async function POST (request : NextRequest ) : Promise < NextResponse > {
try {
const formData = await request. formData ();
const callSid = formData. get ( 'CallSid' ) as string | null ;
const from = formData. get ( 'From' ) as string | null ;
const to = formData. get ( 'To' ) as string | null ;
const callStatus = formData. get ( 'CallStatus' ) as string | null ;
if ( ! callSid || ! from) {
const xml = buildTwiML ( '<Say>Missing required call parameters.</Say><Hangup/>' );
return new NextResponse (xml, {
status: 200 ,
headers: { 'Content-Type' : 'application/xml' },
});
}
// --- Twilio request signature validation ---
const authToken = process.env.TWILIO_AUTH_TOKEN ?? '' ;
if (authToken) {
const rawParams : Record < string , string > = {};
formData. forEach ((value, key) => {
if ( typeof value === 'string' ) {
rawParams[key] = value;
}
});
const signature = request.headers. get ( 'x-twilio-signature' ) ?? '' ;
const isValid = validateTwilioSignature (authToken, signature, request.url, rawParams);
if ( ! isValid) {
console. log ( '[calls] Twilio signature validation failed' );
const xml = buildTwiML ( '<Say>Unauthorized</Say><Hangup/>' );
return new NextResponse (xml, {
status: 403 ,
headers: { 'Content-Type' : 'application/xml' },
});
}
console. log ( '[calls] Twilio signature validation passed' );
}
const apiKey = process.env.LIVEKIT_API_KEY ?? '' ;
const apiSecret = process.env.LIVEKIT_API_SECRET ?? '' ;
const livekitHost = process.env.LIVEKIT_URL ?? '' ;
const sipDomain = process.env.LIVEKIT_SIP_DOMAIN ?? '' ;
if ( ! apiKey || ! apiSecret || ! livekitHost) {
const xml = buildTwiML ( '<Say>Service configuration is incomplete.</Say><Hangup/>' );
return new NextResponse (xml, {
status: 200 ,
headers: { 'Content-Type' : 'application/xml' },
});
}
const roomName = `call-${ callSid }` ;
const roomService = new RoomServiceClient (livekitHost, apiKey, apiSecret);
console. log ( '[calls] Creating LiveKit room:' , roomName);
await roomService. createRoom ({ name: roomName, emptyTimeout: 600 , maxParticipants: 3 });
const token = new AccessToken (apiKey, apiSecret, {
identity: from,
});
token. addGrant ({
roomJoin: true ,
room: roomName,
canPublish: true ,
canSubscribe: true ,
});
const accessToken = token. toJwt ();
console. log ( '[calls] Access token generated for room:' , roomName);
const dispatchResult = await dispatchCall (callSid, '' , from);
console. log ( '[calls] Dispatch result:' , JSON. stringify (dispatchResult));
const resolvedSipDomain = sipDomain || new URL (livekitHost).hostname;
const sipUri = `sip:${ roomName }@${ resolvedSipDomain }` ;
const twimlContent = `<Connect><Sip>${ sipUri }</Sip></Connect>` ;
const xml = buildTwiML (twimlContent);
return new NextResponse (xml, {
status: 200 ,
headers: { 'Content-Type' : 'application/xml' },
});
} catch (error) {
console. error ( '[calls] Error processing call:' , error);
const xml = buildTwiML (
'<Say>An error occurred while processing your call. Please try again later.</Say><Hangup/>' ,
);
return new NextResponse (xml, {
status: 200 ,
headers: { 'Content-Type' : 'application/xml' },
});
}
} import { AgentMemory } from '@reaatech/agent-memory' ;
import { CapabilityBasedRouter, AgentRegistry } from '@reaatech/agent-handoff-routing' ;
import { createSession, appendTurn, closeSession } from '@reaatech/agent-mesh-session' ;
import { VertexAI } from '@google-cloud/vertexai' ;
import type { AgentCapabilities, HandoffPayload, RoutingDecision } from '@reaatech/agent-handoff' ;
export interface DispatchResult {
agentId : string ;
decisionType : 'primary' | 'clarification' | 'fallback' ;
sessionId : string ;
classification : string ;
candidateAgents ?: string [];
}
const vertexAI = new VertexAI ({
project: process.env.GOOGLE_CLOUD_PROJECT,
location: process.env.GOOGLE_CLOUD_LOCATION,
});
const generativeModel = vertexAI. getGenerativeModel ({
model: 'gemini-1.5-flash' ,
});
const CLASSIFICATION_MAP : Record < string , string []> = {
emergency: [ 'emergency' , 'repair' ],
maintenance: [ 'maintenance' , 'inspection' ],
estimate: [ 'estimate' , 'quote' ],
};
async function classifyTranscript (
transcript : string ,
customerHistory : string ,
) : Promise < string > {
const prompt = `Classify the following field service call transcript into one of these categories: emergency, maintenance, estimate, unknown.
Past customer history: ${ customerHistory }
Transcript: "${ transcript }"
Reply with only the category name.` ;
const result = await generativeModel. generateContent ({
contents: [{ role: 'user' , parts: [{ text: prompt }] }],
});
const responseText = result.response?.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.text ?? '' ;
const trimmed = responseText. trim (). toLowerCase ();
if (trimmed. includes ( 'emergency' )) return 'emergency' ;
if (trimmed. includes ( 'maintenance' )) return 'maintenance' ;
if (trimmed. includes ( 'estimate' )) return 'estimate' ;
return 'unknown' ;
}
function mapClassification (classification : string ) : string [] {
return CLASSIFICATION_MAP[classification] ?? [ 'maintenance' ];
}
export async function dispatchCall (
callSid : string ,
transcript : string ,
callerNumber : string ,
) : Promise < DispatchResult > {
try {
// Empty transcript fallback
if ( ! transcript || transcript. trim ().length === 0 ) {
return {
agentId: 'maintenance' ,
decisionType: 'fallback' ,
sessionId: '' ,
classification: 'unknown' ,
};
}
// Retrieve customer history from Agent Memory
const customerHistory = await memory. retrieve (callerNumber, { limit: 5 });
const historyContext =
customerHistory. map ((m) => m.content). join ( '; ' ) || 'No prior history' ;
// Classify the transcript with Vertex AI
let classification : string ;
try {
classification = await classifyTranscript (transcript, historyContext);
} catch {
classification = 'unknown' ;
}
const requiredSkills = mapClassification (classification);
const payload = buildHandoffPayload (requiredSkills, callerNumber, callSid);
// Route to the best agent via CapabilityBasedRouter
let decision : RoutingDecision ;
try {
decision = await router. route (payload, registry. getAll ());
} catch {
return { agentId: 'maintenance' , decisionType: 'fallback' , sessionId: '' , classification };
}
// Resolve the routing decision
let agentId : string ;
let decisionType : 'primary' | 'clarification' | 'fallback' ;
if (decision.type === 'primary' ) {
agentId = decision.targetAgent.agentId;
decisionType = 'primary' ;
} else if (decision.type === 'clarification' ) {
agentId = decision.candidateAgents[ 0 ]?.agentId ?? 'maintenance' ;
decisionType = 'clarification' ;
} else {
agentId = decision.fallbackAgent?.agentId ?? 'maintenance' ;
decisionType = 'fallback' ;
}
// Create or resume session, then append turns
let sessionId = '' ;
try {
const session = await createSession ({
userId: callerNumber,
employeeId: callSid,
activeAgent: agentId,
});
sessionId = session.session_id;
await appendTurn (sessionId, {
role: 'user' ,
content: transcript,
timestamp: new Date (). toISOString (),
intent_summary: classification,
});
await appendTurn (sessionId, {
role: 'agent' ,
content: `Classified as ${ classification }, routed to ${ agentId }` ,
timestamp: new Date (). toISOString (),
});
} catch {
return { agentId, decisionType, sessionId: '' , classification };
}
// Extract and store caller memories for future interactions
try {
await memory. extractAndStore ([
{ speaker: 'user' , content: transcript, timestamp: new Date () },
{ speaker: 'agent' , content: `Routed to ${ agentId }` , timestamp: new Date () },
]);
} catch {
// Memory extraction failure is non-fatal
}
return { agentId, decisionType, sessionId, classification };
} catch {
return { agentId: 'maintenance' , decisionType: 'fallback' , sessionId: '' , classification: 'unknown' };
}
}
export async function endCall (sessionId : string ) : Promise < void > {
if (sessionId) {
try {
await closeSession (sessionId, 'completed' );
} catch {
// Non-fatal
}
}
} import { VertexAI } from '@google-cloud/vertexai' ;
import { DeepgramClient } from '@deepgram/sdk' ;
import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js' ;
import { dispatchCall, endCall } from './dispatcher' ;
import { checkRequest, recordUsage } from '../lib/budget' ;
import type { CheckResult } from '../lib/budget' ;
export interface VoiceSessionState {
roomName : string ;
accessToken : string ;
sessionId : string ;
callSid : string ;
callerNumber : string ;
transcript : string ;
disconnected : boolean ;
}
export interface SessionContext {
sessionId : string ;
callerNumber : string ;
callSid : string ;
turnHistory : Array <{ role : string ; content : string }>;
}
const deepgramClient = new DeepgramClient ();
const elevenLabsClient = new ElevenLabsClient ();
const vertexAI = new VertexAI ({
project: process.env.GOOGLE_CLOUD_PROJECT,
location: process.env.GOOGLE_CLOUD_LOCATION,
});
const geminiModel = vertexAI. getGenerativeModel ({
model: 'gemini-1.5-pro' ,
});
const systemPrompt = `You are a field service dispatch assistant for a company handling plumbing, HVAC, and electrical services.
Your role is to:
1. Listen to the customer's description of their issue
2. Classify the urgency (emergency, maintenance, or estimate)
3. Ask clarifying questions when needed
4. Route to the appropriate team
5. Provide clear, helpful responses
Keep responses concise and professional. Always confirm the customer's callback number and address before ending the call.` ;
export async function startVoiceSession (
roomName : string ,
accessToken : string ,
) : Promise < VoiceSessionState > {
const state : VoiceSessionState = {
roomName,
accessToken,
sessionId: '' ,
callSid: roomName,
callerNumber: '' ,
transcript: '' ,
disconnected: false ,
};
console. log ( '[pipeline] Starting voice session:' , roomName);
try {
console. log ( '[pipeline] Connecting Deepgram WebSocket STT...' );
const connectOptions = {
model: 'nova-3' ,
language: 'en' ,
punctuate: 'true' ,
interim_results: 'true' ,
Authorization: `Token ${ process . env . DEEPGRAM_API_KEY }` ,
} as const ;
const deepgramConnection = await deepgramClient.listen.v1. connect (connectOptions as never );
deepgramConnection. on ( 'open' , () => {
console. log ( '[pipeline] Deepgram WebSocket connection opened' );
});
deepgramConnection. on ( 'message' , (data : unknown ) => {
const msg = data as Record < string , unknown >;
if (msg.type === 'Results' ) {
const resultData = msg as { channel ?: { alternatives ?: Array <{ transcript : string }> } };
const transcript = resultData.channel?.alternatives?.[ 0 ]?.transcript ?? '' ;
if (transcript) {
console. log ( '[pipeline] STT transcript received:' , transcript);
state.transcript = transcript;
}
}
});
console. log ( '[pipeline] Voice session initialized successfully' );
} catch (err) {
console. error ( '[pipeline] Error initializing voice session:' , err);
}
return state;
}
export async function generateAgentResponse (
transcript : string ,
sessionContext : SessionContext ,
) : Promise < string > {
// Check budget before invoking Gemini
let actualModel = 'gemini-1.5-pro' ;
let checkResult : CheckResult | undefined ;
try {
checkResult = checkRequest ( 'gemini-1.5-pro' , { input: 500 , output: 200 });
if (checkResult.suggestedModel) {
actualModel = checkResult.suggestedModel;
}
} catch {
return 'I apologize, but our system is currently unable to process requests. Please try again later.' ;
}
try {
console. log ( `[pipeline] Generating response with model: ${ actualModel }` );
const chatHistory = sessionContext.turnHistory
. map ((turn) => `${ turn . role }: ${ turn . content }` )
. join ( '\n' );
const fullPrompt = `${ systemPrompt }\n\nConversation history:\n${ chatHistory }\n\nCustomer: ${ transcript }\n\nDispatch response:` ;
const activeModel = actualModel !== 'gemini-1.5-pro'
? vertexAI. getGenerativeModel ({ model: actualModel })
: geminiModel;
const result = await activeModel. generateContent ({
contents: [{ role: 'user' , parts: [{ text: fullPrompt }] }],
});
const responseText = result.response?.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.text ?? null ;
if ( ! responseText) {
const dispatchResult = await dispatchCall (
sessionContext.callSid,
transcript,
sessionContext.callerNumber,
);
return `Your call has been classified as a ${ dispatchResult . classification } request and routed to our ${ dispatchResult . agentId } team.` ;
}
// Record usage for budget tracking
const inputTokens = Math. round (transcript.length / 4 );
const outputTokens = Math. round (responseText.length / 4 );
const cost = 0.001 ;
recordUsage ({
requestId: `req-${ sessionContext . callSid }-${ Date . now () }` ,
modelId: actualModel,
inputTokens,
outputTokens,
cost,
});
return responseText;
} catch (err) {
console. error ( '[pipeline] Gemini response generation error:' , err);
try {
const dispatchResult = await dispatchCall (
sessionContext.callSid,
transcript,
sessionContext.callerNumber,
);
return `Your call has been classified as a ${ dispatchResult . classification } request and routed to our ${ dispatchResult . agentId } team.` ;
} catch {
return 'I am routing your request to the appropriate team.' ;
}
}
}
export async function handleCallerHangup (
callSid : string ,
sessionId : string ,
) : Promise < void > {
console. log ( `[pipeline] Caller hung up: ${ callSid }` );
if (sessionId) {
try {
await endCall (sessionId);
} catch (err) {
console. error ( '[pipeline] Error ending call:' , err);
}
}
} return
db;
}
import {
BudgetController,
PricingProvider,
} from '@reaatech/agent-budget-engine' ;
import { BudgetScope, BudgetExceededError, BudgetCheckResult } from '@reaatech/agent-budget-types' ;
import type { SpendEntry } from '@reaatech/agent-budget-types' ;
import { SpendStore } from '@reaatech/agent-budget-spend-tracker' ;
import { getFirestoreDb } from './firestore-admin' ;
import { Firestore } from 'firebase-admin/firestore' ;
const VERTEX_PRICING : Record < string , { inputCostPer1K : number ; outputCostPer1K : number }> = {
'gemini-1.5-pro' : { inputCostPer1K: 0.00375 , outputCostPer1K: 0.015 },
'gemini-1.5-flash' : { inputCostPer1K: 0.000075 , outputCostPer1K: 0.0003 },
};
export class VertexPricingProvider implements PricingProvider {
estimateCost (modelId : string , estimatedInputTokens : number ) : number {
const pricing = VERTEX_PRICING[modelId];
if ( ! pricing) {
const fallback = VERTEX_PRICING[ 'gemini-1.5-flash' ] ! ;
return (estimatedInputTokens / 1000 ) * fallback.inputCostPer1K;
}
return (estimatedInputTokens / 1000 ) * pricing.inputCostPer1K;
}
estimateFullCost (modelId : string , inputTokens : number , outputTokens : number ) : number {
const pricing = VERTEX_PRICING[modelId];
if ( ! pricing) {
const fallback = VERTEX_PRICING[ 'gemini-1.5-flash' ] ! ;
return (
(inputTokens / 1000 ) * fallback.inputCostPer1K +
(outputTokens / 1000 ) * fallback.outputCostPer1K
);
}
return (
(inputTokens / 1000 ) * pricing.inputCostPer1K +
(outputTokens / 1000 ) * pricing.outputCostPer1K
);
}
}
// FirestoreSpendStore extends the in-memory SpendStore with atomic Firestore writes
// (full implementation in the downloadable artifact)
export const spendStore = new FirestoreSpendStore ();
export const pricingProvider = new VertexPricingProvider ();
export const budgetController = new BudgetController ({
spendTracker: spendStore,
pricing: pricingProvider,
});
const BUDGET_SCOPE = BudgetScope.Org;
const BUDGET_KEY = 'default' ;
const DEFAULT_MONTHLY_LIMIT = 50.0 ;
export function getMonthlyLimit () : number {
return Number (process.env.BUDGET_MONTHLY_LIMIT_USD) || DEFAULT_MONTHLY_LIMIT;
}
export function initBudget () : void {
budgetController. defineBudget ({
scopeType: BUDGET_SCOPE,
scopeKey: BUDGET_KEY,
limit: getMonthlyLimit (),
policy: {
softCap: 0.8 ,
hardCap: 1.0 ,
autoDowngrade: [{ from: [ 'gemini-1.5-pro' ], to: 'gemini-1.5-flash' }],
disableTools: [],
},
});
}
export interface CheckResult {
allowed : boolean ;
suggestedModel ?: string ;
disabledTools ?: string [];
}
export function checkRequest (
modelId : string ,
estimatedTokens : { input : number ; output : number },
) : CheckResult {
initBudget ();
const estimatedCost = pricingProvider. estimateFullCost (
modelId,
estimatedTokens.input,
estimatedTokens.output,
);
const result : BudgetCheckResult = budgetController. check ({
scopeType: BUDGET_SCOPE,
scopeKey: BUDGET_KEY,
estimatedCost,
modelId,
tools: [],
});
if ( ! result.allowed) {
const state = budgetController. getState (BUDGET_SCOPE, BUDGET_KEY);
const spent = state?.spent ?? 0 ;
const limit = state?.limit ?? getMonthlyLimit ();
throw new BudgetExceededError (
`Budget exceeded for ${ modelId }` ,
{ scopeType: BUDGET_SCOPE, scopeKey: BUDGET_KEY },
spent,
limit,
result.remaining,
result.action,
);
}
return {
allowed: result.allowed,
suggestedModel: result.suggestedModel,
disabledTools: result.disabledTools,
};
}
export function recordUsage (record : {
requestId : string ;
modelId : string ;
inputTokens : number ;
outputTokens : number ;
cost : number ;
}) : void {
budgetController. record ({
requestId: record.requestId,
scopeType: BUDGET_SCOPE,
scopeKey: BUDGET_KEY,
cost: record.cost,
inputTokens: record.inputTokens,
outputTokens: record.outputTokens,
modelId: record.modelId,
provider: 'vertex' ,
timestamp: new Date (),
});
}
export function getRemainingBudget () : { spent : number ; remaining : number ; state : string } {
const state = budgetController. getState (BUDGET_SCOPE, BUDGET_KEY);
if ( ! state) {
return { spent: 0 , remaining: getMonthlyLimit (), state: 'Active' };
}
return {
spent: state.spent,
remaining: state.remaining,
state: state.state,
};
} import { VertexAI } from '@google-cloud/vertexai' ;
export interface HealthCheckResult {
status : 'healthy' | 'degraded' | 'unhealthy' ;
timestamp : string ;
checks : Array <{
service : string ;
status : 'pass' | 'fail' ;
latencyMs : number ;
error ?: string ;
}>;
}
async function probeWithTimeout < T >(
label : string ,
fn : () => Promise < T >,
timeoutMs : number = 3000 ,
) : Promise <{
service : string ;
status : 'pass' | 'fail' ;
latencyMs : number ;
error ?: string ;
}> {
const start = Date. now ();
try {
await Promise . race ([
fn (),
new Promise < never >((_, reject) =>
setTimeout (() => reject ( new Error ( 'Service probe timed out' )), timeoutMs),
),
]);
return { service: label, status: 'pass' , latencyMs: Date. now () - start };
} catch (err) {
return {
service: label,
status: 'fail' ,
latencyMs: Date. now () - start,
error: err instanceof Error ? err.message : String (err),
};
}
}
export async function runHealthChecks () : Promise < HealthCheckResult > {
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const probes : Promise <{
service : string ;
status : 'pass' | 'fail' ;
latencyMs : number ;
error ?: string ;
}>[] = [
probeWithTimeout ( 'Twilio' , async () => {
if ( ! accountSid || ! authToken) {
throw new Error ( 'Twilio credentials not configured' );
}
const twilioMod = await import ( 'twilio' );
const twilioClient = (twilioMod.default || twilioMod)(accountSid, authToken);
await twilioClient.api.v2010. accounts (accountSid). fetch ();
}),
probeWithTimeout ( 'LiveKit' , async () => {
const livekitHost = process.env.LIVEKIT_URL;
const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
if ( ! livekitHost || ! apiKey || ! apiSecret) {
throw new Error ( 'LiveKit credentials not configured' );
}
const { RoomServiceClient } = await import ( 'livekit-server-sdk' );
const client = new RoomServiceClient (livekitHost, apiKey, apiSecret);
await client. listRooms ();
}),
probeWithTimeout ( 'VertexAI' , async () => {
const project = process.env.GOOGLE_CLOUD_PROJECT;
const location = process.env.GOOGLE_CLOUD_LOCATION;
if ( ! project || ! location) {
throw new Error ( 'Vertex AI credentials not configured' );
}
const vertexAI = new VertexAI ({ project, location });
const model = vertexAI. getGenerativeModel ({ model: 'gemini-1.5-flash' });
await model. countTokens ({
contents: [{ role: 'user' , parts: [{ text: 'ping' }] }],
});
}),
probeWithTimeout ( 'Deepgram' , async () => {
if ( ! process.env.DEEPGRAM_API_KEY) {
throw new Error ( 'Deepgram API key not configured' );
}
const { DeepgramClient } = await import ( '@deepgram/sdk' );
const client = new DeepgramClient ();
await client.listen.v1.media. transcribeFile (
Buffer. from ([ 0x00 ]),
{ model: 'nova-3' },
);
}),
probeWithTimeout ( 'ElevenLabs' , async () => {
if ( ! process.env.ELEVENLABS_API_KEY) {
throw new Error ( 'ElevenLabs API key not configured' );
}
const { ElevenLabsClient } = await import ( '@elevenlabs/elevenlabs-js' );
const client = new ElevenLabsClient ();
await client.voices. search ({});
}),
];
const results = await Promise . all (probes);
const failures = results. filter ((r) => r.status === 'fail' ).length;
let overallStatus : 'healthy' | 'degraded' | 'unhealthy' ;
if (failures === 0 ) {
overallStatus = 'healthy' ;
} else if (failures <= 2 ) {
overallStatus = 'degraded' ;
} else {
overallStatus = 'unhealthy' ;
}
if (overallStatus === 'unhealthy' ) {
console. error (JSON. stringify ({
level: 'alert' ,
message: 'Voice agent health check: unhealthy' ,
failures: results. filter ((r) => r.status === 'fail' ). map ((r) => r.service),
}));
}
return {
status: overallStatus,
timestamp: new Date (). toISOString (),
checks: results,
};
} 'healthy'
) {
httpStatus = 200 ;
} else if (result.status === 'degraded' ) {
httpStatus = 200 ;
} else {
httpStatus = 503 ;
}
return NextResponse. json (result, {
status: httpStatus,
headers: { 'Cache-Control' : 'no-cache' },
});
}
"service"
:
"Deepgram"
,
"status"
:
"pass"
,
"latencyMs"
:
210
},
{ "service" : "ElevenLabs" , "status" : "pass" , "latencyMs" : 155 }
]
}
expectedKey
=
process.env.DASHBOARD_API_KEY;
if ( ! apiKey || apiKey !== expectedKey) {
return new NextResponse (
JSON. stringify ({ error: 'Unauthorized: invalid or missing API key' }),
{
status: 401 ,
headers: { 'Content-Type' : 'application/json' },
},
);
}
return NextResponse. next ();
}
export const config = {
matcher: '/dashboard/:path*' ,
};
<style>{ `
.dashboard-layout { min-height: 100vh; background: #f3f4f6; }
.dashboard-header { background: #1e3a5f; color: white; padding: 16px 24px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.dashboard-header h1 { margin: 0; font-size: 20px; font-weight: 600; }
.dashboard-main { max-width: 1200px; margin: 0 auto; padding: 24px; }
` }</style>
</div>
);
}
<
BudgetCard
/>
</ ErrorBoundary >
</section>
<section className = "dashboard-section logs-section" >
<h2>Call Logs</h2>
< ErrorBoundary >
< CallLogTable />
</ ErrorBoundary >
</section>
<style>{ `
.dashboard-content { display: flex; flex-direction: column; gap: 24px; }
.dashboard-section h2 { margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: #111827; }
` }</style>
</div>
);
}
;
}
interface ErrorBoundaryState {
hasError : boolean ;
error : Error | null ;
}
export default class ErrorBoundary extends Component< ErrorBoundaryProps , ErrorBoundaryState > {
constructor (props : ErrorBoundaryProps ) {
super(props);
this.state = { hasError: false , error: null };
}
static getDerivedStateFromError (error : Error ) : ErrorBoundaryState {
return { hasError: true , error };
}
componentDidCatch (error : Error , errorInfo : ErrorInfo ) : void {
console. error ( '[ErrorBoundary] Caught error:' , error, errorInfo);
}
render () : ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className = "error-boundary-fallback" >
<p>Something went wrong while loading this section.</p>
<button
type = "button"
onClick ={() => this . setState ({ hasError: false , error: null })}
>
Retry
</button>
<style>{ `
.error-boundary-fallback {
border: 1px solid #fca5a5;
border-radius: 8px;
padding: 24px;
text-align: center;
background: #fef2f2;
}
.error-boundary-fallback p {
color: #b91c1c;
margin: 0 0 12px 0;
font-size: 14px;
}
.error-boundary-fallback button {
background: #ef4444;
color: white;
border: none;
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.error-boundary-fallback button:hover {
background: #dc2626;
}
` }</style>
</div>
);
}
return this.props.children;
}
}
'use client' ;
import { useState, useEffect, useCallback } from 'react' ;
interface BudgetData {
spent : number ;
remaining : number ;
limit : number ;
state : string ;
}
export default function BudgetCard () {
const [budget, setBudget] = useState < BudgetData | null >( null );
const [loading, setLoading] = useState < boolean >( true );
const [error, setError] = useState < string | null >( null );
const fetchBudget = useCallback ( async () => {
setLoading ( true );
setError ( null );
try {
const response = await fetch ( '/api/budget' );
if ( ! response.ok) {
throw new Error ( `Request failed with status ${ response . status }` );
}
const data = await response. json ();
setBudget ({
spent: data.spent,
remaining: data.remaining,
limit: data.limit,
state: data.state,
});
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load budget data' ;
setError (message);
} finally {
setLoading ( false );
}
}, []);
useEffect (() => {
fetchBudget ();
}, [fetchBudget]);
if (loading) {
return (
<div className = "budget-card" >
<div className = "skeleton-title" />
<div className = "skeleton-bar" />
<div className = "skeleton-row-short" />
<style>{ `
.budget-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; background: white; }
.skeleton-title { height: 20px; width: 40%; background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; margin-bottom: 16px; }
.skeleton-bar { height: 24px; width: 100%; background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; margin-bottom: 12px; }
.skeleton-row-short { height: 16px; width: 60%; background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
` }</style>
</div>
);
}
if (error) {
return (
<div className = "budget-card-error" >
<p>Error: { error }</p>
<button onClick ={ fetchBudget } type = "button" >Retry</button>
<style>{ `
.budget-card-error { border: 1px solid #fca5a5; border-radius: 8px; padding: 24px; text-align: center; background: #fef2f2; }
.budget-card-error p { color: #b91c1c; margin: 0 0 12px 0; font-size: 14px; }
.budget-card-error button { background: #ef4444; color: white; border: none; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; }
.budget-card-error button:hover { background: #dc2626; }
` }</style>
</div>
);
}
if ( ! budget) return null ;
const spendPercent =
budget.limit > 0
? Math. min (Math. round ((budget.spent / budget.limit) * 100 ), 100 )
: 0 ;
const barColor =
spendPercent >= 90 ? '#dc2626' :
spendPercent >= 75 ? '#d97706' :
'#2563eb' ;
return (
<div className = "budget-card" >
<h3>Monthly Spend</h3>
<div className = "budget-bar-container" >
<div
className = "budget-bar-fill"
style ={{ width: `${ spendPercent }%` , backgroundColor: barColor }}
/>
</div>
<div className = "budget-stats" >
<div className = "budget-stat" >
<span className = "budget-label" >Spent</span>
<span className = "budget-value" >${ budget . spent . toFixed ( 2 )}</span>
</div>
<div className = "budget-stat" >
<span className = "budget-label" >Remaining</span>
<span className = "budget-value" >${ budget . remaining . toFixed ( 2 )}</span>
</div>
<div className = "budget-stat" >
<span className = "budget-label" >Limit</span>
<span className = "budget-value" >${ budget . limit . toFixed ( 2 )}</span>
</div>
</div>
<div className = "budget-state" >
<span className ={ `state-badge state-${ budget . state . toLowerCase () }` }>
{ budget . state }
</span>
</div>
<style>{ `
.budget-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; background: white; }
.budget-card h3 { margin: 0 0 16px 0; font-size: 16px; font-weight: 600; color: #111827; }
.budget-bar-container { height: 10px; background: #e5e7eb; border-radius: 5px; overflow: hidden; margin-bottom: 16px; }
.budget-bar-fill { height: 100%; border-radius: 5px; transition: width 0.3s ease; }
.budget-stats { display: flex; justify-content: space-between; margin-bottom: 12px; }
.budget-stat { display: flex; flex-direction: column; }
.budget-label { font-size: 12px; color: #6b7280; }
.budget-value { font-size: 16px; font-weight: 600; color: #111827; }
.budget-state { text-align: right; }
.state-badge { display: inline-block; padding: 3px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.state-active { background: #d1fae5; color: #065f46; }
.state-warning { background: #fef3c7; color: #92400e; }
.state-exceeded { background: #fee2e2; color: #991b1b; }
` }</style>
</div>
);
} 'use client' ;
import { useState, useEffect, useCallback } from 'react' ;
interface CallLogEntry {
id : string ;
timestamp : string ;
caller : string ;
intent : string ;
agent : string ;
status : string ;
}
export default function CallLogTable () {
const [logs, setLogs] = useState < CallLogEntry []>([]);
const [loading, setLoading] = useState < boolean >( true );
const [error, setError] = useState < string | null >( null );
const fetchLogs = useCallback ( async () => {
setLoading ( true );
setError ( null );
try {
const response = await fetch ( '/api/calls/logs' );
if ( ! response.ok) {
throw new Error ( `Request failed with status ${ response . status }` );
}
const data = await response. json ();
setLogs (data.logs);
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load call logs' ;
setError (message);
} finally {
setLoading ( false );
}
}, []);
useEffect (() => {
fetchLogs ();
}, [fetchLogs]);
if (loading) {
return (
<div className = "call-log-table" >
<div className = "skeleton-header" >
<div className = "skeleton-cell" style ={{ width: '15%' }} />
<div className = "skeleton-cell" style ={{ width: '20%' }} />
<div className = "skeleton-cell" style ={{ width: '20%' }} />
<div className = "skeleton-cell" style ={{ width: '15%' }} />
<div className = "skeleton-cell" style ={{ width: '15%' }} />
</div>
{ Array . from ({ length: 5 }). map (( _ , index ) => (
<div className = "skeleton-row" key ={ index }>
<div className = "skeleton-cell" style ={{ width: '15%' }} />
<div className = "skeleton-cell" style ={{ width: '20%' }} />
<div className = "skeleton-cell" style ={{ width: '20%' }} />
<div className = "skeleton-cell" style ={{ width: '15%' }} />
<div className = "skeleton-cell" style ={{ width: '15%' }} />
</div>
))}
<style>{ `
.call-log-table { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; }
.skeleton-header, .skeleton-row { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid #f3f4f6; }
.skeleton-header { border-bottom: 2px solid #e5e7eb; }
.skeleton-cell { height: 16px; background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
` }</style>
</div>
);
}
if (error) {
return (
<div className = "call-log-error" >
<p>Error: { error }</p>
<button onClick ={ fetchLogs } type = "button" >Retry</button>
<style>{ `
.call-log-error { border: 1px solid #fca5a5; border-radius: 8px; padding: 24px; text-align: center; background: #fef2f2; }
.call-log-error p { color: #b91c1c; margin: 0 0 12px 0; font-size: 14px; }
.call-log-error button { background: #ef4444; color: white; border: none; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; }
.call-log-error button:hover { background: #dc2626; }
` }</style>
</div>
);
}
if (logs.length === 0 ) {
return (
<div className = "call-log-empty" >
<p>No call logs found.</p>
<style>{ `
.call-log-empty { border: 1px solid #e5e7eb; border-radius: 8px; padding: 32px; text-align: center; color: #6b7280; font-size: 14px; }
` }</style>
</div>
);
}
return (
<div className = "call-log-table" >
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Caller</th>
<th>Intent</th>
<th>Agent</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{ logs . map (( entry ) => (
<tr key ={ entry . id }>
<td>{new Date ( entry . timestamp ). toLocaleString ()}</td>
<td>{ entry . caller }</td>
<td>{ entry . intent }</td>
<td>{ entry . agent }</td>
<td>
<span className ={ `status-badge status-${ entry . status . toLowerCase () }` }>
{ entry . status }
</span>
</td>
</tr>
))}
</tbody>
</table>
<style>{ `
.call-log-table { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { background: #f9fafb; text-align: left; padding: 12px 16px; font-weight: 600; color: #374151; border-bottom: 2px solid #e5e7eb; }
td { padding: 10px 16px; border-bottom: 1px solid #f3f4f6; color: #4b5563; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f9fafb; }
.status-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.status-completed { background: #d1fae5; color: #065f46; }
.status-in_progress { background: #dbeafe; color: #1e40af; }
.status-pending { background: #fef3c7; color: #92400e; }
.status-failed { background: #fee2e2; color: #991b1b; }
` }</style>
</div>
);
} ,
'http://localhost:7880'
);
vi. stubEnv ( 'LIVEKIT_API_KEY' , 'test-livekit-api-key' );
vi. stubEnv ( 'LIVEKIT_API_SECRET' , 'test-livekit-api-secret' );
vi. stubEnv ( 'GOOGLE_CLOUD_PROJECT' , 'test-project' );
vi. stubEnv ( 'GOOGLE_CLOUD_LOCATION' , 'us-central1' );
vi. stubEnv ( 'DEEPGRAM_API_KEY' , 'test-deepgram-api-key' );
vi. stubEnv ( 'ELEVENLABS_API_KEY' , 'test-elevenlabs-api-key' );
vi. stubEnv ( 'OPENAI_API_KEY' , 'test-openai-api-key' );
vi. stubEnv ( 'BUDGET_MONTHLY_LIMIT_USD' , '100' );
const server = setupServer ( ... handlers);
beforeAll (() => server. listen ({ onUnhandledRequest: 'warn' }));
afterAll (() => server. close ());
afterEach (() => {
server. resetHandlers ();
vi. clearAllMocks ();
});