Skip to content

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:

  1. Standalone IVR Flows: IVR flows are independent agents (agentType: 'ivr_flow') rather than embedded configuration within voice agents
  2. Independent TTS Configuration: IVR flows have their own ivrTtsConfig with preset-based voice resolution
  3. Seamless Handoff: IVR flows can hand off to voice agents, recorded message agents, or transfer to external numbers
  4. 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:

TypeDescriptionUse Case
voice_agentAI-powered conversational agentCustomer service, sales calls
ivr_flowInteractive menu system (DTMF/voice)Call routing, self-service
recorded_messagePre-recorded or TTS message playbackAnnouncements, reminders

Data Model ​

IVR Flow Agent Schema ​

typescript
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 ​

http
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) ​

http
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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

python
# 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:

python
# 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()
    return

IVR → Agent Handoff ​

When an IVR flow hands off to another agent:

  1. Resolve Target Agent: Call /internal/resolve-agent-for-handoff with target agent ID
  2. Route by Type:
    • voice_agent: Rebuild session with full STT/LLM/TTS pipeline
    • recorded_message: Play message, then end call
    • ivr_flow: Reject (IVR → IVR not supported)

Voice Agent Handoff ​

python
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 ​

python
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:

python
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 separate LocalAudioTrack to 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:

  1. IVR session starts with delete_room_on_close=False
  2. Recording captures: IVR prompts, DTMF tones, voice agent conversation, transfer calls
  3. Single room = single recording file
  4. 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:

json
{
  "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 (has promptText)
  • actionType: "repeat" / "submenu" / "connect_agent" / "transfer" Valid DTMF press
  • actionType: "invalid_digit" User pressed a digit not in bindings
  • actionType: "timeout" No input received within timeout period

UI Transcript Merger ​

The frontend merges ivrInteractions with the voice agent transcription into a unified chronological view:

  1. When ivrInteractions exists: buildUnifiedTranscript() builds a combined timeline
  2. When ivrInteractions is 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 timeline

File Changes ​

Client Backend (Node.js) ​

FileChange
controllers/telephony.controller.tsresolveInboundNumber now returns agentType, sipTrunkId, sipPhoneNumber
controllers/webhook.controller.tsAdded resolveAgentForHandoff endpoint with type-specific resolution
controllers/contact.controller.tsAdded ivrInteractions to listContacts .select() projection
models/Contact.tsAdded promptText field to ivrInteractions schema
routes/webhook.routes.tsAdded /internal/resolve-agent-for-handoff route
routes/telephony.routes.tsUpdated exports for internal routes

Agent Server (Python) ​

FileChange
agent.pyAdded _run_inbound_ivr_flow(), _resolve_ivr_agent_config() helpers; inbound routing for IVR
src/handlers/ivr_flow_handler.pystart_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.pybuild_ivr_tts_session() for TTS-only sessions

Frontend (React) ​

FileChange
lib/ivr-transcript-merger.tsNew utility merges ivrInteractions + transcription into unified timeline
pages/customer/HistoryPage.tsxUses unified transcript merger; added ivrInteractions to attempt object
pages/customer/CampaignDetailPage.tsxUses unified transcript merger
pages/customer/InboundCallsPage.tsxUses unified transcript merger
pages/customer/AgentsPage.tsxIVR card shows preset name instead of TTS provider
components/ivr-builder/ConfigPanel.tsxUses LanguageSelector component; fixed useMemo deps for voice filtering
components/ivr-builder/FlowCanvas.tsxAdded hand/pan mode toggle button in controls

Testing ​

Unit tests are in backend/client_backend/src/controllers/__tests__/ivr-flow-resolution.test.ts:

bash
cd backend/client_backend
npm test -- ivr-flow-resolution

Test Coverage:

  • resolveInboundNumber returns correct agentType for all agent types
  • resolveAgentForHandoff returns correct structure for IVR, voice, recorded message agents
  • Edge cases: legacy format, empty config, missing agent

Troubleshooting ​

SymptomCauseFix
Inbound call routes to voice agent instead of IVRagentType check runs before resolutionFixed in agent.py check happens AFTER resolution
IVR voice doesn't match configurationVoice uid not resolved to provider voice_idFixed resolveAgentVoiceForDispatch() called in resolution
Recorded message ends immediatelyIdentity mismatch (contactUID vs SIP identity)Fixed use _sip_participant_identity
Transfer target hung up but caller still connectedMissing disconnect handlerFixed added participant_disconnected handler after handoff
IVR → IVR handoff attemptedUser configured connect_agent to another IVRRejected by design say error message, don't crash
Transcription checkpoint warnings after handoffperiodic_transcription_checkpoint called with wrong argsFixed pass session=new_session, meta=self.meta
IVR transcript not showing in History pageivrInteractions not in listContacts .select()Fixed added to projection
IVR agent card shows "deepgram" instead of preset nameCard displayed tts_provider raw valueFixed now resolves ivrTtsConfig.preset_id to preset name
IVR language selector missing languagesConfigPanel used inline filter instead of LanguageSelectorFixed now uses same LanguageSelector as Agent Form

Last updated: