Skip to content
/ solutions / anthropic-voice-agent-for-calendly-appointment-booking Anthropic Voice Agent for Calendly Appointment Booking A voice assistant that uses Calendly's API to seamlessly schedule appointments, handling natural language availability queries and booking conflicts.
The problem Small business owners waste hours playing phone tag to schedule appointments; manual coordination often leads to double bookings or missed opportunities.
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.
100 tests · 97.7% 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
By the end of this tutorial you’ll have a running voice agent that answers phone calls, classifies caller intent using a confidence router, looks up appointment availability via Calendly’s REST API, and hands off to a human receptionist if the caller requests it or the budget runs out. The server runs on Express, so you’ll interact with it entirely over HTTP webhooks.
Prerequisites
Node.js >= 22
pnpm 10.x (npm install -g pnpm)
Accounts and API keys for:
Anthropic (for Claude LLM calls)
Calendly (for appointment scheduling)
LiveKit (for the voice pipeline and webhook signing)
Deepgram (for speech-to-text)
Cartesia (for text-to-speech)
Twilio (for incoming phone calls and call transfers)
Basic familiarity with TypeScript and Express route handlers
Step 1: Initialize the project
Create a fresh Node.js project and install the exact dependencies the recipe requires. The artifact pins specific versions — match those exactly to avoid compatibility issues.
mkdir anthropic-voice-calendly-agent && cd anthropic-voice-calendly-agent
pnpm init -y
pnpm add \
@anthropic-ai/sdk@0.32.0 \
@cartesia/cartesia-js@3.0.0 \
@deepgram/sdk@5.2.0 \
@livekit/agents@1.4.2 \
@livekit/agents-plugin-cartesia@1.4.2 \
@livekit/agents-plugin-deepgram@1.4.2 \
@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/confidence-router@0.1.0 \
dotenv@17.4.2 \
express@4.21.1 \
pino@9.5.0 \
nanoid@5.1.11 \
twilio@6.0.2
pnpm add -D \
@types/express@5.0.0 \
@types/node@22.9.0 \
@types/supertest@7.2.0 \
@vitest/coverage-v8@2.1.5 \
eslint@9.15.0 \
msw@2.6.6 \
supertest@7.2.2 \
typescript@5.6.3 \
typescript-eslint@8.15.0 \
vitest@2.1.5
Expected output: The install runs without errors and node_modules/ contains all packages. No ERR_* output.
Step 2: Configure environment variables Create a .env file at the project root with every variable the server needs. The server reads these at startup via dotenv and validates required keys in src/config.ts — missing a required variable will crash the server with a ConfigurationError.
Copy .env.example to .env and fill in your values:
ANTHROPIC_API_KEY=<your-anthropic-key>
CALENDLY_API_KEY=<your-calendly-key>
LIVEKIT_API_KEY=<your-livekit-key>
LIVEKIT_API_SECRET=<your-livekit-secret>
LIVEKIT_URL=<your-livekit-url>
DEEPGRAM_API_KEY=<your-deepgram-key>
CARTESIA_API_KEY=<your-cartesia-key>
TWILIO_ACCOUNT_SID=<your-twilio-sid>
TWILIO_AUTH_TOKEN=<your-twilio-token>
TWILIO_PHONE_NUMBER=<your-twilio-phone>
HUMAN_RECEPTIONIST_PHONE=<transfer-number>
APP_LOG_LEVEL=<log-level>
AGENT_BUDGET_LIMIT_USD=<budget-usd>
PORT=<server-port>
Step 3: Set up TypeScript and project structure Create a tsconfig.json so TypeScript knows how to compile the project:
{
"compilerOptions" : {
"target" : "ES2022" ,
"module" : "NodeNext" ,
"moduleResolution" : "NodeNext" ,
"strict" : true ,
"esModuleInterop" : true ,
"forceConsistentCasingInFileNames" : true ,
"skipLibCheck" : true ,
"resolveJsonModule" : true ,
"isolatedModules" : true ,
"noUncheckedIndexedAccess" : true ,
"exactOptionalPropertyTypes" : true ,
"outDir" : "dist"
},
"include" : [ "src/**/*" , "tests/**/*" , "*.config.ts" , "*.config.mjs" ]
} Create the directory layout:
mkdir -p src/routes src/agent src/lib src/types tests/routes tests/lib tests/agent Create vitest.config.ts at the root:
import { defineConfig } from "vitest/config" ;
export default defineConfig ({
test: {
globals: true ,
environment: "node" ,
coverage: {
provider: "v8" ,
reporter: [ "text" , "json" , "json-summary" ],
reportsDirectory: "./coverage" ,
include: [ "src/**/*.ts" , "app/**/route.ts" ],
exclude: [
"node_modules/**" ,
"dist/**" ,
"coverage/**" ,
"**/*.config.{ts,mjs,js}" ,
"**/*.d.ts" ,
"**/*.test.ts" ,
"**/*.test.tsx" ,
"**/*.tsx" ,
"app/**/layout.ts" ,
"app/**/error.ts" ,
"app/**/loading.ts" ,
"app/**/not-found.ts" ,
],
thresholds: {
lines: 90 ,
branches: 90 ,
functions: 90 ,
statements: 90 ,
},
},
},
});
Step 4: Build the Calendly client The CalendlyClient in src/lib/calendly-client.ts wraps Calendly’s REST API (https://api.calendly.com) with Bearer token auth. It handles user lookup, event type listing, available slot queries, booking creation, and cancellation — with proper error handling for non-ok responses.
Create src/lib/calendly-types.ts:
export interface CalendlyUser {
uri : string ;
}
export interface CalendlyUserResponse {
resource : CalendlyUser ;
}
export interface EventType {
uri : string ;
name : string ;
duration : number ;
active : boolean ;
}
export interface EventTypesResponse {
collection : EventType [];
}
export interface AvailableSlot {
start : string ;
end : string ;
}
export interface AvailableTimesResponse {
collection : AvailableSlot [];
}
export interface ScheduledEvent {
uri : string ;
name : string ;
start_time : string ;
status : string ;
}
export interface ScheduledEventResponse {
resource : ScheduledEvent ;
}
export interface CancellationResponse {
resource : {
uri : string ;
status : string ;
cancellation ?: {
canceled_by : string ;
reason : string ;
};
};
} Create src/lib/calendly-client.ts:
import type {
AvailableSlot,
AvailableTimesResponse,
CalendlyUserResponse,
CancellationResponse,
EventType,
EventTypesResponse,
ScheduledEvent,
ScheduledEventResponse,
} from "./calendly-types.js" ;
export class CalendlyError extends Error {
statusCode : number ;
responseBody : unknown ;
constructor (message : string , statusCode : number , responseBody : unknown ) {
super(message);
this.name = "CalendlyError" ;
this.statusCode = statusCode;
this.responseBody
Step 5: Implement the intent router with ConfidenceRouter The intent router uses @reaatech/confidence-router to classify incoming voice transcripts and decide what action to take — book, cancel, reschedule, ask hours, or escalate to a human. When confidence is low, it asks for clarification instead of guessing.
Create src/lib/intent-router.ts:
import { ConfidenceRouter, KeywordClassifier } from "@reaatech/confidence-router" ;
import type { RoutingDecision } from "@reaatech/agent-handoff" ;
const router = new ConfidenceRouter ({
routeThreshold: 0.8 ,
fallbackThreshold: 0.3 ,
clarificationEnabled: true ,
});
router. registerClassifier (
new KeywordClassifier ([
{ label: "book" , keywords: [ "book" , "schedule" , "appointment" , "reserve" ] },
{ label: "cancel" , keywords: [ "cancel" , "remove" , "delete" ] },
{ label: "ask_hours" , keywords: [ "hours" , "available" , "when" , "open" ] },
{ label: "reschedule" , keywords: [ "reschedule" , "change" , "move" ] },
{ label: "human" , keywords: [ "agent" , "operator" , "person" , "human" , "talk to someone" ] },
]),
);
export async function routeIntent (transcript : string ) : Promise < RoutingDecision > {
const decision = await router. process (transcript);
if (decision.type === "CLARIFY" ) {
return {
type: "clarification" ,
candidateAgents: [],
clarificationQuestions: [decision.prompt ?? "Could you clarify what you need?" ],
recommendedAction: "ask_user" ,
};
}
if (decision.type === "FALLBACK" ) {
return {
type: "fallback" ,
reason: "low_confidence" ,
queueForLater: false ,
};
}
return {
type: "primary" ,
targetAgent: {
agentId: decision.target ?? "ask_hours" ,
agentName: decision.target ?? "ask_hours" ,
skills: [],
domains: [],
maxConcurrentSessions: 1 ,
currentLoad: 0 ,
languages: [ "en" ],
specializations: [],
availability: "available" ,
version: "1.0.0" ,
},
confidence: decision.confidence ?? 0.9 ,
alternatives: [],
};
}
export function optimizeThresholds (dataset : { examples : Array <{ input : string ; expectedLabel : string }> }) : void {
const optimal = router. optimizeThresholds (dataset);
router. updateConfig ({
routeThreshold: optimal.routeThreshold,
fallbackThreshold: optimal.fallbackThreshold,
});
}
Step 6: Wire up the budget controller and pricing provider The budget controller from @reaatech/agent-budget-engine enforces per-session spend limits on LLM calls. When the hard cap is hit, it triggers a human handoff automatically. The withBudgetCheck wrapper calls the budget controller before every LLM request, throwing BudgetExceededError if the limit would be exceeded.
Create src/lib/pricing-provider.ts:
import { BudgetValidationError } from "@reaatech/agent-budget-types" ;
export interface PricingProvider {
estimateCost (args : { model : string ; inputTokens : number ; outputTokens : number }) : number ;
}
export class AnthropicPricingProvider implements PricingProvider {
private prices : Record < string , { input : number ; output : number }> = {
"claude-opus-4-7" : { input: 15 , output: 75 },
"claude-sonnet-4-6" : { input: 3 , output: 15 },
"claude-haiku-4-5-20251001" : { input: 0.8 , output: 4 },
};
estimateCost (args : { model : string ; inputTokens : number ; outputTokens : number }) : number {
const price = this.prices[args.model];
if ( ! price) {
throw new BudgetValidationError ( `Unknown model: ${ args . model }` );
}
const inputCost = (args.inputTokens / 1_000_000 ) * price.input;
const outputCost = (args.outputTokens / 1_000_000 ) * price.output;
return inputCost + outputCost;
}
}
export function estimateCost (model : string , inputTokens : number , outputTokens : number ) : number {
return new AnthropicPricingProvider (). estimateCost ({ model, inputTokens, outputTokens });
} Create src/lib/spend-store.ts:
import { SpendStore } from "@reaatech/agent-budget-spend-tracker" ;
export const spendStore = new SpendStore (); Create src/lib/budget-manager.ts:
import { BudgetController } from "@reaatech/agent-budget-engine" ;
import { BudgetScope } from "@reaatech/agent-budget-types" ;
import { spendStore } from "./spend-store.js" ;
import { AnthropicPricingProvider } from "./pricing-provider.js" ;
const pricingProvider = new AnthropicPricingProvider ();
let controller : BudgetController | undefined ;
function estimateCost (modelId : string , estimatedInputTokens : number ) : number {
return pricingProvider. estimateCost ({
model: modelId,
inputTokens: estimatedInputTokens,
outputTokens: Math. floor (estimatedInputTokens / 2 ),
});
}
export function createBudgetController (spendLimitUsd : number ) : BudgetController {
controller = new BudgetController ({
spendTracker: spendStore,
pricing: { estimateCost },
defaultEstimateTokens: 2000 ,
});
controller. defineBudget ({
scopeType: BudgetScope.User,
scopeKey: "*" ,
limit: spendLimitUsd,
policy: {
softCap: 0.8 ,
hardCap: 1.0 ,
autoDowngrade: [
{ from: [ "claude-opus-4-7" ], to: "claude-sonnet-4-6" },
{ from: [ "claude-sonnet-4-6" ], to: "claude-haiku-4-5-20251001" },
],
disableTools: [],
},
});
return controller;
}
export function getBudgetController () : BudgetController {
if (controller === undefined ) {
throw new Error ( "BudgetController not initialized. Call createBudgetController first." );
}
return controller;
}
export { BudgetController, BudgetScope };
export { BudgetExceededError, BudgetValidationError } from "@reaatech/agent-budget-types" ;
export { estimateCost }; Create src/lib/llm-budget-wrapper.ts:
import { BudgetExceededError, BudgetScope } from "@reaatech/agent-budget-types" ;
import { getBudgetController } from "./budget-manager.js" ;
import { AnthropicPricingProvider } from "./pricing-provider.js" ;
const pricingProvider = new AnthropicPricingProvider ();
export interface BudgetCheckOptions {
modelId : string ;
estimatedInputTokens : number ;
estimatedOutputTokens : number ;
scopeType : BudgetScope ;
scopeKey : string ;
}
export async function withBudgetCheck < T >(
fn : () => Promise < T >,
options : BudgetCheckOptions ,
) : Promise < T > {
const controller = getBudgetController ();
const estimatedCost = pricingProvider. estimateCost ({
model: options.modelId,
inputTokens: options.estimatedInputTokens,
outputTokens: options.estimatedOutputTokens,
});
const result = controller. check ({
scopeType: options.scopeType,
scopeKey: options.scopeKey,
estimatedCost,
modelId: options.modelId,
tools: [],
});
if ( ! result.allowed) {
throw new BudgetExceededError (
`Budget exceeded for ${ options . scopeType }:${ options . scopeKey }` ,
{ scopeType: options.scopeType, scopeKey: options.scopeKey },
result.limit - result.remaining,
result.limit,
result.remaining,
result.action,
);
}
const response = await fn ();
const actualCost = pricingProvider. estimateCost ({
model: result.suggestedModel ?? options.modelId,
inputTokens: options.estimatedInputTokens,
outputTokens: options.estimatedOutputTokens,
});
controller. record ({
requestId: `req-${ String ( Date . now ()) }` ,
scopeType: options.scopeType,
scopeKey: options.scopeKey,
cost: actualCost,
inputTokens: options.estimatedInputTokens,
outputTokens: options.estimatedOutputTokens,
modelId: result.suggestedModel ?? options.modelId,
provider: "anthropic" ,
timestamp: new Date (),
});
return response;
}
Step 7: Create the handoff manager and Twilio client The handoff manager uses @reaatech/agent-handoff to transfer a call to a human receptionist. It emits typed events, retries on transient Twilio errors, and throws structured errors (TimeoutError, TransportError) when the transfer fails.
Create src/lib/twilio-client.ts:
import twilio from "twilio" ;
export interface TwilioCallOptions {
to : string ;
from : string ;
url : string ;
}
export function createTwilioClient (accountSid : string , authToken : string ) {
return twilio (accountSid, authToken);
}
export async function makeCall (
client : ReturnType < typeof createTwilioClient>,
options : TwilioCallOptions ,
) {
return client.calls. create ({
to: options.to,
from: options.from,
url: options.url,
});
} Create src/lib/handoff-manager.ts:
import {
TimeoutError,
TransportError,
TypedEventEmitter,
withRetry,
} from "@reaatech/agent-handoff" ;
import { createTwilioClient } from "./twilio-client.js" ;
export interface HandoffPayload {
sessionSummary : string ;
userInfo : { name : string ; phone : string };
currentIntent : string ;
conversationHistory : Array <{ role : string ; content : string }>;
escalationReason : string ;
}
interface HandoffEvents {
escalate : { callSid : string ; reason : string };
success : { callSid : string };
failure : { callSid : string ; error : string };
}
export class HandoffManager extends TypedEventEmitter< HandoffEvents > {
private accountSid : string ;
private authToken : string ;
constructor (accountSid : string , authToken : string ) {
super();
this.accountSid = accountSid;
this.authToken = authToken;
}
async escalateToHuman (
callSid : string ,
reason : string ,
humanNumber : string ,
) : Promise < void > {
this. emit ( "escalate" , { callSid, reason });
const client = createTwilioClient (this.accountSid, this.authToken);
const twiml = `<Response><Dial><Number>${ humanNumber }</Number></Dial></Response>` ;
try {
await withRetry (
async () => {
await client. calls (callSid). update ({ twiml });
},
{
maxRetries: 3 ,
backoff: "exponential" ,
baseDelayMs: 100 ,
maxDelayMs: 2000 ,
shouldRetry : () => true ,
},
);
this. emit ( "success" , { callSid });
} catch (error) {
const message = error instanceof Error ? error.message : String (error);
this. emit ( "failure" , { callSid, error: message });
if (message. includes ( "timed out" ) || message. includes ( "timeout" )) {
throw new TimeoutError ( `Handoff timed out: ${ message }` , 30000 );
}
throw new TransportError ( `Twilio handoff failed: ${ message }` );
}
}
buildPayload (
callSid : string ,
reason : string ,
history : Array <{ role : string ; content : string }>,
userInfo : { name : string ; phone : string },
intent : string ,
) : HandoffPayload {
return {
sessionSummary: `Escalated call ${ callSid }: ${ reason }` ,
userInfo,
currentIntent: intent,
conversationHistory: history,
escalationReason: reason,
};
}
}
Step 8: Build the agent worker and Express routes The agent worker orchestrates the voice session. It maintains conversation state (idle, collecting name, confirming, etc.), routes each user transcript through withBudgetCheck before calling the intent router, and handles each intent by calling the Calendly client or triggering a human handoff.
Create src/agent/conversation-state.ts:
export enum ConversationState {
Idle = "idle" ,
CollectingName = "collecting_name" ,
CollectingDate = "collecting_date" ,
CollectingTime = "collecting_time" ,
Confirming = "confirming" ,
Completed = "completed" ,
} Create src/agent/agent-worker.ts:
import type { BudgetController } from "@reaatech/agent-budget-engine" ;
import { BudgetScope } from "@reaatech/agent-budget-types" ;
import { logger } from "../logger.js" ;
import { CalendlyClient } from "../lib/calendly-client.js" ;
import { HandoffManager } from "../lib/handoff-manager.js" ;
import { routeIntent } from "../lib/intent-router.js" ;
import { withBudgetCheck } from "../lib/llm-budget-wrapper.js" ;
import { ConversationState } from "./conversation-state.js" ;
import type { Config } from "../config.js" ;
export interface AgentContext {
roomName : string
Now create the two Express route handlers: one for Twilio incoming calls (returns TwiML to bridge the call to a LiveKit room) and one for the LiveKit webhook (verifies the HMAC signature before spawning an agent worker).
Create src/routes/voice-webhook.ts:
import { Router } from "express" ;
import { createHmac } from "crypto" ;
import { logger } from "../logger.js" ;
import { startAgentForRoom } from "../agent/agent-worker.js" ;
import { getBudgetController } from "../lib/budget-manager.js" ;
import { HandoffManager } from "../lib/handoff-manager.js" ;
import type { Config } from "../config.js" ;
interface WebhookPayload {
event ?: string ;
room ?: { name ?: string };
}
export function createVoiceWebhookRouter (config : Config ) : Router {
const router = Router ();
const budgetController = getBudgetController ();
const handoffManager = new HandoffManager (
config.twilioAccountSid,
config.twilioAuthToken,
);
router. post ( "/voice-webhook" , (req, res) => {
try {
const signature = req.headers.authorization ?? "" ;
const rawBody = JSON. stringify (req.body);
const expected = `Bearer ${ createHmac ( "sha256" , config . livekitApiSecret ). update ( rawBody ). digest ( "hex" ) }` ;
if (signature !== expected) {
res. status ( 403 ). json ({ error: "Invalid signature" });
return ;
}
const payload = req.body as WebhookPayload ;
const event = payload.event ?? "" ;
const eventType = String (event);
const roomName = payload.room?.name ?? "" ;
if ( ! roomName) {
res. status ( 400 ). json ({ error: "Missing room name" });
return ;
}
res. status ( 200 ). json ({ received: true });
if (eventType === "room_started" ) {
startAgentForRoom (roomName, config, budgetController, handoffManager);
} else if (eventType === "participant_left" ) {
logger. info ({ roomName }, "Participant left, cleaning up session" );
}
} catch (error) {
logger. error ({ error }, "Webhook handler error" );
res. status ( 500 ). json ({ error: "Internal server error" });
}
});
return router;
} Create src/routes/voice-incoming.ts:
import { Router } from "express" ;
import { nanoid } from "nanoid" ;
import { logger } from "../logger.js" ;
import type { Config } from "../config.js" ;
export function createVoiceIncomingRouter (config : Config ) : Router {
const router = Router ();
router. post ( "/voice-incoming" , (_req, res) => {
const roomName = `room-${ nanoid ( 10 ) }` ;
const livekitUrlString = String (config.livekitUrl);
const streamUrl = `${ livekitUrlString . replace ( "wss://" , "wss://" ) }/room/${ roomName }` ;
logger. info ({ roomName }, "Incoming Twilio call, creating room" );
const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Dial><Stream url="${ streamUrl }"></Stream></Dial></Response>` ;
res. setHeader ( "Content-Type" , "application/xml" );
res. status ( 200 ). send (twiml);
});
return router;
}
Step 9: Wire the server and run the tests Create the server entry point and supporting config files, then run the test suite.
import { config } from "dotenv" ;
import {
ConfigurationError as HandoffConfigurationError,
} from "@reaatech/agent-handoff" ;
config ();
export const ConfigurationError = HandoffConfigurationError;
export interface Config {
anthropicApiKey : string ;
calendlyApiKey : string ;
livekitApiKey : string ;
livekitApiSecret : string ;
livekitUrl : string ;
deepgramApiKey : string ;
cartesiaApiKey : string ;
twilioAccountSid : string ;
twilioAuthToken : string ;
twilioPhoneNumber : string ;
humanReceptionistPhone : string ;
appLogLevel : string ;
agentBudgetLimitUsd : number ;
port : number ;
}
export function loadConfig () : Config {
const required = [
"ANTHROPIC_API_KEY" ,
"LIVEKIT_API_KEY" ,
"LIVEKIT_API_SECRET" ,
"LIVEKIT_URL" ,
"TWILIO_ACCOUNT_SID" ,
"TWILIO_AUTH_TOKEN" ,
] as const ;
for ( const key of required) {
const value = process.env[key];
if ( ! value) {
throw new ConfigurationError ( `Missing required environment variable: ${ key }` );
}
}
return {
anthropicApiKey: process.env.ANTHROPIC_API_KEY as string ,
calendlyApiKey: process.env.CALENDLY_API_KEY ?? "" ,
livekitApiKey: process.env.LIVEKIT_API_KEY as string ,
livekitApiSecret: process.env.LIVEKIT_API_SECRET as string ,
livekitUrl: process.env.LIVEKIT_URL as string ,
deepgramApiKey: process.env.DEEPGRAM_API_KEY ?? "" ,
cartesiaApiKey: process.env.CARTESIA_API_KEY ?? "" ,
twilioAccountSid: process.env.TWILIO_ACCOUNT_SID as string ,
twilioAuthToken: process.env.TWILIO_AUTH_TOKEN as string ,
twilioPhoneNumber: process.env.TWILIO_PHONE_NUMBER ?? "" ,
humanReceptionistPhone: process.env.HUMAN_RECEPTIONIST_PHONE ?? "" ,
appLogLevel: process.env.APP_LOG_LEVEL ?? "info" ,
agentBudgetLimitUsd: Number (process.env.AGENT_BUDGET_LIMIT_USD ?? "10" ),
port: Number (process.env.PORT ?? "3000" ),
};
} import { pino } from "pino" ;
let loggerInstance : ReturnType < typeof pino> | undefined ;
export function getLogger () : ReturnType < typeof pino> {
if ( ! loggerInstance) {
loggerInstance = pino ({
level: process.env.APP_LOG_LEVEL ?? "info" ,
timestamp: pino.stdTimeFunctions.isoTime,
});
}
return loggerInstance;
}
export const logger = getLogger (); Create src/lib/error-handlers.ts:
import type { Request, Response, NextFunction } from "express" ;
import { logger } from "../logger.js" ;
export function globalErrorHandler (
err : Error ,
_req : Request ,
res : Response ,
_next : NextFunction ,
) : void {
void _next;
logger. error ({ stack: err.stack, message: err.message }, "Unhandled error" );
res. status ( 500 ). json ({ error: err.message, code: "internal_error" });
} import express from "express" ;
import dotenv from "dotenv" ;
import { loadConfig } from "./config.js" ;
import { logger } from "./logger.js" ;
import { globalErrorHandler } from "./lib/error-handlers.js" ;
import { createVoiceWebhookRouter } from "./routes/voice-webhook.js" ;
import { createVoiceIncomingRouter } from "./routes/voice-incoming.js" ;
dotenv. config ();
const config = loadConfig ();
const app = express ();
app. use (express. json ());
app. use (express. urlencoded ({ extended: true }));
app. use ( createVoiceWebhookRouter (config));
app. use ( createVoiceIncomingRouter (config));
app. use (globalErrorHandler);
app. listen (config.port, () => {
logger. info ({ port: config.port }, "Server listening" );
});
export { app }; Run the typecheck to verify no TypeScript errors:
Expected output: No errors — Found 0 errors.
Expected output: All tests pass, with coverage reported in JSON format to vitest-report.json. The coverage thresholds in vitest.config.ts require 90% on all metrics — the suite validates the Calendly client, intent router, budget manager, handoff manager, and both Express routes.
Next steps
Replace the in-memory SpendStore with a Redis or Postgres-backed implementation so budget state survives server restarts.
Add a LiveKit agent worker that bridges the Express webhook events to an actual voice pipeline using @livekit/agents with Deepgram STT and Cartesia TTS.
=
responseBody;
}
}
export class CalendlyClient {
private apiKey : string ;
private baseUrl : string ;
constructor (apiKey : string , baseUrl = "https://api.calendly.com" ) {
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
private async request < T >(path : string , options : RequestInit = {}) : Promise < T > {
const url = `${ this . baseUrl }${ path }` ;
const headers : Record < string , string > = {
Authorization: `Bearer ${ this . apiKey }` ,
"Content-Type" : "application/json" ,
"Calendly-Version" : "2022-10-08" ,
... (options.headers as Record < string , string > | undefined ),
};
try {
const response = await fetch (url, { ... options, headers });
if ( ! response.ok) {
const body = ( await response. json (). catch (() => ({}))) as Record < string , unknown >;
throw new CalendlyError (
`Calendly API error: ${ String ( response . status ) } ${ response . statusText }` ,
response.status,
body,
);
}
return ( await response. json ()) as T ;
} catch (error) {
if (error instanceof CalendlyError ) {
throw error;
}
throw new CalendlyError (
error instanceof Error ? error.message : "Network error" ,
0 ,
error,
);
}
}
async getUserUri () : Promise < string > {
const data = await this. request < CalendlyUserResponse >( "/users/me" );
return data.resource.uri;
}
async listEventTypes (userUri : string ) : Promise < EventType []> {
const data = await this. request < EventTypesResponse >(
`/event_types?user=${ encodeURIComponent ( userUri ) }` ,
);
return data.collection. map ((et) => ({
uri: et.uri,
name: et.name,
duration: et.duration,
active: et.active,
}));
}
async getAvailableSlots (
eventTypeUri : string ,
startTime : string ,
endTime : string ,
) : Promise < AvailableSlot []> {
const data = await this. request < AvailableTimesResponse >(
`/event_type_available_times?event_type=${ encodeURIComponent ( eventTypeUri ) }&start_time=${ encodeURIComponent ( startTime ) }&end_time=${ encodeURIComponent ( endTime ) }` ,
);
return data.collection. map ((slot) => ({
start: slot.start,
end: slot.end,
}));
}
async createBooking (
eventTypeUri : string ,
startTime : string ,
name : string ,
email : string ,
options ?: { guests ?: string []; notes ?: string },
) : Promise < ScheduledEvent > {
const body = {
event_type: eventTypeUri,
start_time: startTime,
name,
email,
guests: options?.guests ?? [],
invitee_questions_responses: {
questions_and_answers: [
{ question: "Name" , answer: name },
{ question: "Email" , answer: email },
],
},
};
const data = await this. request < ScheduledEventResponse >( "/scheduled_events" , {
method: "POST" ,
body: JSON. stringify (body),
});
return {
uri: data.resource.uri,
name: data.resource.name,
start_time: data.resource.start_time,
status: data.resource.status,
};
}
async cancelBooking (
scheduledEventUri : string ,
reason ?: string ,
) : Promise < CancellationResponse > {
const uuid = scheduledEventUri. split ( "/" ). pop () ?? scheduledEventUri;
const body = { reason: reason ?? "" };
return await this. request < CancellationResponse >(
`/scheduled_events/${ uuid }/cancellation` ,
{
method: "POST" ,
body: JSON. stringify (body),
},
);
}
}
;
callSid : string ;
config : Config ;
budgetController : BudgetController ;
handoffManager : HandoffManager ;
calendlyClient : CalendlyClient ;
}
interface Session {
state : ConversationState ;
userName : string ;
userEmail : string ;
selectedEventTypeUri : string ;
selectedSlot : string ;
history : Array <{ role : string ; content : string }>;
}
const sessions = new Map < string , Session >();
function getSession (roomName : string ) : Session {
const existing = sessions. get (roomName);
if (existing) return existing;
const created : Session = {
state: ConversationState.Idle,
userName: "" ,
userEmail: "" ,
selectedEventTypeUri: "" ,
selectedSlot: "" ,
history: [],
};
sessions. set (roomName, created);
return created;
}
export function startAgentForRoom (
roomName : string ,
config : Config ,
budgetController : BudgetController ,
handoffManager : HandoffManager ,
) : void {
const calendlyClient = new CalendlyClient (config.calendlyApiKey);
const ctx : AgentContext = {
roomName,
callSid: roomName,
config,
budgetController,
handoffManager,
calendlyClient,
};
logger. info ({ roomName }, "Agent started for room" );
budgetController. on ( "threshold-breach" , (data : { threshold : number }) => {
logger. warn ({ roomName, threshold: data.threshold }, "Budget threshold breached" );
});
budgetController. on ( "hard-stop" , () => {
logger. warn ({ roomName }, "Budget hard stop triggered" );
void handoffManager. escalateToHuman (
roomName,
"Budget hard cap reached" ,
config.humanReceptionistPhone,
);
});
void runAgentLoop (ctx);
}
async function runAgentLoop (ctx : AgentContext ) : Promise < void > {
const session = getSession (ctx.roomName);
try {
const userUri = await ctx.calendlyClient. getUserUri ();
const eventTypes = await ctx.calendlyClient. listEventTypes (userUri);
session.history. push ({
role: "system" ,
content:
"You are a voice scheduling assistant. Help users book, cancel, or reschedule appointments. Be concise — this is a voice call. Ask clarifying questions only when intent is ambiguous. When the user asks for a human, transfer to a human agent." ,
});
logger. info ({ roomName: ctx.roomName, eventTypes: eventTypes.length }, "Agent ready" );
} catch (error) {
logger. error ({ roomName: ctx.roomName, error }, "Agent initialization failed" );
}
}
export async function handleUserTranscript (
ctx : AgentContext ,
transcript : string ,
) : Promise < string > {
const session = getSession (ctx.roomName);
session.history. push ({ role: "user" , content: transcript });
const decision = await withBudgetCheck (
() => routeIntent (transcript),
{
modelId: "claude-sonnet-4-6" ,
estimatedInputTokens: transcript.length + 500 ,
estimatedOutputTokens: 200 ,
scopeType: BudgetScope.Session,
scopeKey: ctx.roomName,
},
);
if (decision.type === "clarification" ) {
const prompt = (decision as { prompt ?: string }).prompt ?? "Could you please clarify?" ;
session.state = ConversationState.Confirming;
return prompt;
}
const target = (decision as { targetAgent ?: string }).targetAgent ?? "ask_hours" ;
switch (target) {
case "book" : {
session.state = ConversationState.CollectingName;
return "I'd be happy to book an appointment. May I have your name and email?" ;
}
case "cancel" : {
session.state = ConversationState.CollectingDate;
return "I can help you cancel. Please provide the booking details." ;
}
case "ask_hours" : {
const userUri = await ctx.calendlyClient. getUserUri ();
const eventTypes = await ctx.calendlyClient. listEventTypes (userUri);
const names = eventTypes. map ((et) => et.name). join ( ", " );
return `Our available appointment types are: ${ names }. Which would you like?` ;
}
case "reschedule" : {
session.state = ConversationState.CollectingDate;
return "I can help reschedule. What is your current appointment date?" ;
}
case "human" : {
await ctx.handoffManager. escalateToHuman (
ctx.callSid,
"User requested human agent" ,
ctx.config.humanReceptionistPhone,
);
return "Transferring you to a human agent now." ;
}
default : {
return "I'm not sure I understood. Could you rephrase?" ;
}
}
}