IVR Flow Agent Type
End-to-end documentation for the IVR Flow agent type feature in Oktivo.
Overview
The IVR Flow Agent Type feature introduces IVR (Interactive Voice Response) as a first-class agent type, separate from voice agents. This redesign enables:
- Standalone IVR Flows: IVR flows are independent agents (
agentType: 'ivr_flow') rather than embedded configuration within voice agents - Independent TTS Configuration: IVR flows have their own
ivrTtsConfigwith preset-based voice resolution - Seamless Handoff: IVR flows can hand off to voice agents, recorded message agents, or transfer to external numbers
- Unified Recording: The entire call (IVR prompts + handoff conversation) is recorded as a single continuous file
Architecture
Agent Types
The Agent model supports three types:
| Type | Description | Use Case |
|---|---|---|
voice_agent | AI-powered conversational agent | Customer service, sales calls |
ivr_flow | Interactive menu system (DTMF/voice) | Call routing, self-service |
recorded_message | Pre-recorded or TTS message playback | Announcements, reminders |
Data Model
IVR Flow Agent Schema
interface IvrFlowAgent {
agent_id: string;
AgentName: string;
agentType: 'ivr_flow'; // Immutable after creation
orgUID: string;
userId: string;
preset_id: string; // Required field (shared with voice agents)
// IVR-specific TTS configuration
ivrTtsConfig: {
preset_id: string; // e.g., 'preset_oktivo_flash'
tts_voice: string; // Voice uid (vce_xxx) resolved at runtime
tts_language?: string; // ISO language code
tts_speed: number; // Speech rate (default: 1.0)
customPresetEnabled?: boolean;
ttsConfig?: { // BYOK config
provider: string;
model: string;
key_id: string;
};
};
// IVR flow definition
ivrFlowConfig: {
rootNode: IvrMenuNode; // Entry point
nodes: IvrMenuNode[]; // Submenus
greetingSource: string; // 'node_prompt' | 'custom_message'
routingMode: 'dtmf' | 'voice_intent';
routingGoal?: string;
onCallConnected?: {
enabled: boolean;
dtmfSequence?: DtmfStep[];
};
};
inboundPhoneNumber?: string; // E.164 format
analysis_id?: string;
auto_hangup_time?: number;
}
interface IvrMenuNode {
nodeId: string;
prompt: string;
bindings: Record<string, DtmfAction>;
timeoutSeconds?: number;
timeoutAction?: string;
}
type DtmfAction =
| { type: 'repeat' }
| { type: 'repeat', targetNodeId: string }
| { type: 'submenu', targetNodeId: string }
| { type: 'connect_agent', targetAgentId: string }
| { type: 'transfer', transferTarget: string }
| { type: 'end_call' };API Endpoints
Inbound Number Resolution
POST /internal/resolve-inbound-number
x-api-secret: <WEBHOOK_SECRET>
Request:
{
"dialedNumber": "+919429390801"
}
Response (IVR agent):
{
"success": true,
"agentId": "Q3g9qVS5UZmC",
"agentType": "ivr_flow",
"orgUID": "org_xxx",
"customerUID": null,
"phoneUID": "phn_xxx",
"sipTrunkId": "ST_xxx",
"sipPhoneNumber": "+91..."
}Key Change: Now returns agentType field to enable routing decisions in the Python agent.
Agent Config Resolution (for Handoff)
POST /internal/resolve-agent-for-handoff
x-api-secret: <WEBHOOK_SECRET>
Request:
{
"agentId": "Q3g9qVS5UZmC",
"orgUID": "org_xxx"
}Response varies by agent type:
For ivr_flow:
{
"success": true,
"agentType": "ivr_flow",
"agentConfig": {
"agent_id": "Q3g9qVS5UZmC",
"AgentName": "Customer Service IVR",
"analysis_id": "default",
"auto_hangup_time": 300
},
"ivrTtsConfig": {
"preset_id": "preset_oktivo_flash",
"tts_provider": "deepgram",
"tts_model": "aura-2",
"tts_voice": "aura-asteria-en",
"tts_language": "en",
"tts_speed": 1.0
},
"ivrFlowConfig": { ... }
}For voice_agent:
{
"success": true,
"agentType": "voice_agent",
"agentConfig": {
"agent_id": "13HxF2ucae67",
"Prompt": "You are a helpful assistant...",
"tts_provider": "cartesia",
"tts_model": "sonic-3",
"tts_voice": "95d51f79-c397-46f9-b49a-23763d3eaa2d",
"stt_provider": "deepgram",
"stt_model": "nova-3",
"llm_provider": "openai",
"llm_model": "gpt-4.1-mini",
"realtime_model": false
}
}For recorded_message:
{
"success": true,
"agentType": "recorded_message",
"agentConfig": {
"agent_id": "iJzTqNR5FWaS",
"messageType": "tts",
"ttsMessage": "Hello, this is a reminder...",
"tts_provider": "mistralai",
"tts_voice": "...",
"repeatCount": 2
}
}Routing Logic
Inbound Calls
The Python agent routes inbound calls based on the agentType returned from /internal/resolve-inbound-number:
# agent.py inbound routing
resolution = await resolve_inbound_number(dialed_number)
agent_type = resolution.get('agentType', 'voice_agent')
if agent_type == 'ivr_flow':
# Fetch IVR config (not in dispatch metadata for inbound)
ivr_config = await resolve_agent_for_handoff(resolution['agentId'], org_uid)
handler = IvrFlowHandler(ctx, meta, ivr_config['ivrTtsConfig'], ivr_config['ivrFlowConfig'])
await handler.start_inbound()
return
# Continue to voice agent / recorded message path...Outbound Calls (Campaign Dispatch)
For outbound campaigns, the agentType is included in dispatch metadata:
# agent.py outbound routing
agent_type = meta_dict.get('agentType', 'voice_agent')
if agent_type == 'ivr_flow' and mode != 'inbound':
ivr_tts_config = meta_dict.get('ivrTtsConfig', {})
ivr_flow_config = meta_dict.get('ivrFlowConfig', {})
handler = IvrFlowHandler(ctx, meta, ivr_tts_config, ivr_flow_config)
await handler.start_outbound()
returnIVR → Agent Handoff
When an IVR flow hands off to another agent:
- Resolve Target Agent: Call
/internal/resolve-agent-for-handoffwith target agent ID - Route by Type:
voice_agent: Rebuild session with full STT/LLM/TTS pipelinerecorded_message: Play message, then end callivr_flow: Reject (IVR → IVR not supported)
Voice Agent Handoff
async def _handle_connect_voice_agent(self, target_agent_id, agent_config):
# Build AgentMetaSchema from pre-resolved config
agent_meta = AgentMetaSchema(**agent_config)
# Build VoiceAssistant
voice_agent = VoiceAssistant(mode, meta, agent_meta, ctx)
# Build new session with STT, LLM, TTS
new_session = build_session(ctx, agent_meta, mcp_toolsets, ...)
# Start new session (old IVR session preserved for room continuity)
await new_session.start(room=self.job_ctx.room, agent=voice_agent, ...)
# Register transfer disconnect handler
@self.job_ctx.room.on("participant_disconnected")
def _on_transfer_participant_disconnected(participant): ...
# Say first message, start background tasks
await new_session.say(first_message)Recorded Message Handoff
async def _handle_connect_recorded_message(self, agent_config):
# Use SIP participant identity for presence tracking (NOT contactUID)
rec_meta = RecordedMessageMetadata(
identity=self._sip_participant_identity,
...
)
# Delegate to RecordedMessageHandler
handler = RecordedMessageHandler(ctx, rec_meta)
await handler._play_audio_loop(audio_frames)
await safe_delete_room()Important: The identity for recorded message handlers must be the SIP participant identity (e.g., sip_9548655931), not the contactUID. This ensures presence polling works correctly.
Warm Transfer
IVR warm transfer dials the target into the SAME room:
async def _handle_transfer(self, transfer_target):
# Say "Transferring your call now. Please hold."
await self.session.say("Transferring your call now. Please hold.")
# Start ringback tone in background (440Hz + 480Hz, 2s on / 4s off)
ringback_stop = asyncio.Event()
ringback_task = asyncio.create_task(_play_ringback())
try:
# Dial target into same room (blocks until answered)
await ctx.api.sip.create_sip_participant(
sip_trunk_id=meta.sip_trunk_id,
sip_call_to=transfer_target,
room_name=ctx.room.name,
participant_identity=f"transfer_{target_number}",
wait_until_answered=True,
)
finally:
# Stop ringback tone
ringback_stop.set()
ringback_task.cancel()
# Announce connection
await self.session.say("I'm connecting you now.")
# Disable AI pipeline
ctx._transfer_in_progress = True
# Wait for either party to disconnect
await self._wait_for_transfer_disconnect()Ringback Tone
During the transfer dial phase, the caller hears a standard ringback tone:
- Frequencies: 440Hz + 480Hz (North American standard)
- Cadence: 2 seconds on, 4 seconds silence, repeat
- Volume: 15% amplitude (not too loud)
- Implementation: Generated in pure Python (
math+struct), published as a separateLocalAudioTrackto the room - Cleanup: Track unpublished immediately when transfer target answers or dial fails
- Non-critical: If ringback playback fails, the transfer continues normally (caller just hears silence)
When either party hangs up during a transfer, both are disconnected and the room is deleted.
Recording
Recording starts when the IVR session begins and continues through handoffs:
- IVR session starts with
delete_room_on_close=False - Recording captures: IVR prompts, DTMF tones, voice agent conversation, transfer calls
- Single room = single recording file
- Room deleted only after handoff completes (or call ends)
IVR Interactions & Transcription
ivrInteractions Field
The IvrMenuEngine logs all IVR events to _interactions, which are stored on the Contact document as ivrInteractions. Each entry contains:
{
"timestamp": "2026-08-12T04:58:50.272Z",
"nodeId": "start",
"digit": "2",
"actionType": "repeat",
"error": null,
"promptText": "Press 1 for sales, 2 for support..." // NEW: IVR TTS prompt text
}Entry types:
actionType: "prompt"IVR TTS prompt spoken to caller (haspromptText)actionType: "repeat"/"submenu"/"connect_agent"/"transfer"Valid DTMF pressactionType: "invalid_digit"User pressed a digit not in bindingsactionType: "timeout"No input received within timeout period
UI Transcript Merger
The frontend merges ivrInteractions with the voice agent transcription into a unified chronological view:
- When
ivrInteractionsexists:buildUnifiedTranscript()builds a combined timeline - When
ivrInteractionsis empty/null: Returns null → standard rendering unchanged
IVR → Voice Agent transcript:
IVR: Press 1 for sales, 2 for support...
DTMF: Pressed 2 → Repeat
IVR: Press 1 for sales, 2 for support...
DTMF: Pressed 4 → Connect to agent
Connected to agent —
Agent: Hi! am I speaking with {{name}}?
User: Yes, speaking.
...IVR only (no handoff):
IVR: Press 1 for agent, 2 to repeat...
DTMF: Pressed 2 → Repeat
IVR: Press 1 for agent, 2 to repeat...
No input received (timeout) —Data Flow
IvrMenuEngine._log_interaction()
↓
IvrFlowHandler._execute_ivr_flow() → stores on ctx.ivr_interactions
↓
post_call_event() → includes ivrInteractions in /transcription-sync payload
↓
webhook.controller.ts → transcriptionSync() → stores on Contact.ivrInteractions
↓
contact.controller.ts → listContacts() → returns ivrInteractions in .select()
↓
Frontend → buildUnifiedTranscript() → merged timelineFile Changes
Client Backend (Node.js)
| File | Change |
|---|---|
controllers/telephony.controller.ts | resolveInboundNumber now returns agentType, sipTrunkId, sipPhoneNumber |
controllers/webhook.controller.ts | Added resolveAgentForHandoff endpoint with type-specific resolution |
controllers/contact.controller.ts | Added ivrInteractions to listContacts .select() projection |
models/Contact.ts | Added promptText field to ivrInteractions schema |
routes/webhook.routes.ts | Added /internal/resolve-agent-for-handoff route |
routes/telephony.routes.ts | Updated exports for internal routes |
Agent Server (Python)
| File | Change |
|---|---|
agent.py | Added _run_inbound_ivr_flow(), _resolve_ivr_agent_config() helpers; inbound routing for IVR |
src/handlers/ivr_flow_handler.py | start_inbound(), _handle_connect_agent(), _handle_connect_recorded_message(), _sip_participant_identity, fixed periodic_transcription_checkpoint args |
src/core/ivr_menu_engine.py | _log_interaction() now logs promptText; _handle_transfer() plays ringback tone during dial |
src/core/session_builder.py | build_ivr_tts_session() for TTS-only sessions |
Frontend (React)
| File | Change |
|---|---|
lib/ivr-transcript-merger.ts | New utility merges ivrInteractions + transcription into unified timeline |
pages/customer/HistoryPage.tsx | Uses unified transcript merger; added ivrInteractions to attempt object |
pages/customer/CampaignDetailPage.tsx | Uses unified transcript merger |
pages/customer/InboundCallsPage.tsx | Uses unified transcript merger |
pages/customer/AgentsPage.tsx | IVR card shows preset name instead of TTS provider |
components/ivr-builder/ConfigPanel.tsx | Uses LanguageSelector component; fixed useMemo deps for voice filtering |
components/ivr-builder/FlowCanvas.tsx | Added hand/pan mode toggle button in controls |
Testing
Unit tests are in backend/client_backend/src/controllers/__tests__/ivr-flow-resolution.test.ts:
cd backend/client_backend
npm test -- ivr-flow-resolutionTest Coverage:
resolveInboundNumberreturns correctagentTypefor all agent typesresolveAgentForHandoffreturns correct structure for IVR, voice, recorded message agents- Edge cases: legacy format, empty config, missing agent
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Inbound call routes to voice agent instead of IVR | agentType check runs before resolution | Fixed in agent.py check happens AFTER resolution |
| IVR voice doesn't match configuration | Voice uid not resolved to provider voice_id | Fixed resolveAgentVoiceForDispatch() called in resolution |
| Recorded message ends immediately | Identity mismatch (contactUID vs SIP identity) | Fixed use _sip_participant_identity |
| Transfer target hung up but caller still connected | Missing disconnect handler | Fixed added participant_disconnected handler after handoff |
| IVR → IVR handoff attempted | User configured connect_agent to another IVR | Rejected by design say error message, don't crash |
| Transcription checkpoint warnings after handoff | periodic_transcription_checkpoint called with wrong args | Fixed pass session=new_session, meta=self.meta |
| IVR transcript not showing in History page | ivrInteractions not in listContacts .select() | Fixed added to projection |
| IVR agent card shows "deepgram" instead of preset name | Card displayed tts_provider raw value | Fixed now resolves ivrTtsConfig.preset_id to preset name |
| IVR language selector missing languages | ConfigPanel used inline filter instead of LanguageSelector | Fixed now uses same LanguageSelector as Agent Form |
Related Documentation
- IVR Call Transfer Warm transfer implementation
- Inbound Calling System Per-provider inbound model
- BYOK Bring Your Own Key for custom TTS providers