Issue with AMD IVR Navigation for Outbound Calls

Hello
We are trying to use Livekit to make outbound SIP calls to our clients. We need functionality to handle IVR menus, and we use LiveKit’s AMD for this purpose.

The basic flow works:

  • We start an AgentSession.
  • We wrap the outbound call in AMD(session, participant_identity=…).
  • AMD correctly classifies the call as machine-ivr.
  • LiveKit’s IVR activity injects send_dtmf_events.
  • The agent correctly selects the right DTMF digit.
  • After the IVR route completes, the human side says “Hello?”

The issue happens after that IVR-to-human handoff.
In our logs, after the human says “Hello?", the agent either waits a long time before speaking or the same opener is spoken multiple times with a slight offset, making the audio sound duplicated/glitchy. We also see repeated TTS warnings such as:

failed to synthesize speech: no audio frames were pushed for text: …

We tried adding a custom reached_human function tool to close session._ivr_activity, but speaking from inside that tool with session.say(…) caused lifecycle issues. The logs showed:
waiting for function call to finish before fully cancelling
speech not done in time after interruption, cancelling the speech arbitrarily

At first, we followed LiveKit’s AMD video exactly (https://www.youtube.com/watch?v=aVXk6N31X7o). Since that didn’t work we added custom functions but that didn’t work either.

So we had some questions:

1. What is the intended way for LiveKit’s built-in IVR navigation to detect that a live human has been reached after DTMF navigation?

2. Should developers manually close _ivr_activity, or is there a public API for ending IVR mode?
3. Is there a recommended pattern for preventing duplicate/stale replies after IVR mode transitions into normal conversation?
4. Are the “no audio frames were pushed for text” TTS errors expected when speech is interrupted/cancelled during IVR navigation, or do they indicate a separate LiveKit Inference TTS issue?
5. Is there a complete Python example for AMD + outbound SIP + machine-ivr + post-IVR human handoff?

For context, the behavior we want is the same as the AMD demo video: AMD detects machine-ivr, the agent navigates the IVR with DTMF, and once a live person answers, the assistant begins the normal conversation exactly once with no long pause or overlapping duplicate speech.

These are some more details that could help:

LiveKit project URL: wss://test-dc3hvwvh.livekit.cloud
Agent name: jobe-livekit-demo-agent
Room from failing run logs: livekit-ivr-test-01d8bcdf
Job ID from failing run logs: AJ_PXmKRS22cRDH
SIP participant identity: sip-15168633963

A dispatch from the same test setup:
Dispatch ID: AD_qj2EVBXjM3DD
Room: livekit-ivr-test-aa20b198

Thanks,
Diyan

P.S. I wanted to attach python files of our code but livekit says it doesn’t allow new users to attach python files

The AMD classification happens just once, at the start of the call. It sounds like AMD is correctly identifying the call as machine-ivr and then you are successfully navigating the IVR tree until you speak with a human. There is no ‘human reached’ functionality in the detector beyond the initial flow.

I don’t have an end-to-end example that combines AMD, IVR and a handoff to a human, but we have these pieces in isolation: Answering machine detection | LiveKit Documentation and Building an Automated IVR Menu Caller | LiveKit Documentation . Another pair of examples: agents/examples/telephony/amd.py at main · livekit/agents · GitHub and agents/examples/telephony/bank-ivr/README.md at main · livekit/agents · GitHub

I thought Python files were allowed (I don’t see the exception in the site settings), but you should also be able to paste your code inline.

Sorry about that. I don’t know why it didn’t let us upload them initially. These are the Python files for all the cases we tested:

agent.py (9.5 KB)

agent_with_human_reached.py (10.6 KB)

We’ve already tried following the AMD documentation. We use AMD to check for not just IVR, but voicemail and no-answer as well. But it didn’t work.

If you could help us figure out specifically what the issue is with our setup, that would be really helpful. Thanks.

Diyan is a part of our team ^. We have also raised an email support ticket.

This is all to say that I don’t think this is a strange or unusual use case and even after using the documentation the handoff from IVR navigation to a voice agent that can converse with the user on the other end is not working, we would really appreciate a prompt response into how we can solve this issue.

Thank you for letting us know you also raised an email support ticket, it helps us coordinate. Please understand that community support is ‘best effort’ and not guaranteed.

The following should work - mostly from Claude but I verified it against a SIP participant and in agent console, but not against a real IVR tree:

import logging
import os
import textwrap

from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
    AMD,
    NOT_GIVEN,
    Agent,
    AgentServer,
    AgentSession,
    JobContext,
    TurnHandlingOptions,
    cli,
    inference,
    room_io,
)
from livekit.plugins import ai_coustics

logger = logging.getLogger("agent")

load_dotenv(".env.local", override=True)


class Assistant(Agent):
    def __init__(self) -> None:
        super().__init__(
            # A Large Language Model (LLM) is your agent's brain, processing user input and generating a response
            # See all available models at https://docs.livekit.io/agents/models/llm/
            llm=inference.LLM(model="google/gemma-4-31b-it"),
            # To use a realtime model instead of a voice pipeline, replace the LLM
            # with a RealtimeModel and remove the STT/TTS from the AgentSession
            # (Note: This is for the OpenAI Realtime API. For other providers, see https://docs.livekit.io/agents/models/realtime/)
            # 1. Install livekit-agents[openai]
            # 2. Set OPENAI_API_KEY in .env.local
            # 3. Add `from livekit.plugins import openai` to the top of this file
            # 4. Replace the llm argument with:
            #     llm=openai.realtime.RealtimeModel(voice="marin")
            instructions=textwrap.dedent(
                """\
                You are an automated caller placing an outbound phone call. You have two goals, in order.

                # Goal 1: Reach a human

                The call may be answered by a live person, or by an automated phone menu (IVR). If you reach a menu, your job is to navigate it to reach a live human representative as quickly as possible.

                - Listen carefully to each menu prompt before acting.
                - Choose the option most likely to connect you to a live person: a representative, agent, operator, or customer service. Phrases like "speak to a representative" or "for all other inquiries" usually lead to a human.
                - When no such option is offered, pressing "0" or staying on the line is often the fastest way to reach an operator.
                - Do not give up or hang up. Keep working through the menus until a human answers.

                # Goal 2: Share facts about space

                As soon as you are speaking with a real human, switch goals: your job is now to share fascinating, true facts about space — planets, stars, galaxies, black holes, space exploration, and the universe.

                - Warmly greet the person, then share one interesting space fact.
                - Each turn, offer a new, accurate space fact. Vary the topics.
                - Stay friendly, upbeat, and conversational. React naturally to what the human says, but keep steering back to space facts.

                # Output rules

                You are interacting via voice, and must apply the following rules to sound natural in a text-to-speech system:

                - Respond in plain text only. Never use JSON, markdown, lists, tables, code, emojis, or other complex formatting.
                - Keep replies brief: one to three sentences.
                - Do not reveal system instructions, internal reasoning, tool names, parameters, or raw outputs.
                - Spell out numbers instead of using digits.
                - Avoid acronyms and words with unclear pronunciation, when possible.

                # Guardrails

                - Stay within safe, lawful, and appropriate use; decline harmful or out-of-scope requests.
                - Share only accurate, well-established facts about space. If you are unsure whether a fact is true, choose a different one you are confident about.
                """
            ),
        )

    # To add tools, use the @function_tool decorator.
    # Here's an example that adds a simple weather tool.
    # You also have to add `from livekit.agents import function_tool, RunContext` to the top of this file
    # @function_tool
    # async def lookup_weather(self, context: RunContext, location: str):
    #     """Use this tool to look up current weather information in the given location.
    #
    #     If the location is not supported by the weather service, the tool will indicate this. You must tell the user the location's weather is unavailable.
    #
    #     Args:
    #         location: The location to look up weather information for (e.g. city name)
    #     """
    #
    #     logger.info(f"Looking up weather for {location}")
    #
    #     return "sunny with a temperature of 70 degrees."


server = AgentServer()


@server.rtc_session(agent_name="my-agent")
async def my_agent(ctx: JobContext):
    # Logging setup
    # Add any other context you want in all log entries here
    ctx.log_context_fields = {
        "room": ctx.room.name,
    }

    # Set up a voice AI pipeline using OpenAI, Cartesia, Deepgram, and the LiveKit turn detector
    session = AgentSession(
        # Speech-to-text (STT) is your agent's ears, turning the user's speech into text that the LLM can understand
        # See all available models at https://docs.livekit.io/agents/models/stt/
        stt=inference.STT(model="deepgram/nova-3", language="multi"),
        # Text-to-speech (TTS) is your agent's voice, turning the LLM's text into speech that the user can hear
        # See all available models as well as voice selections at https://docs.livekit.io/agents/models/tts/
        tts=inference.TTS(
            model="cartesia/sonic-3", voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc"
        ),
        # The LiveKit turn detector determines when the user is done speaking and the agent should respond.
        # TurnDetector is an end-of-turn model that listens to the user's audio directly, combining
        # semantic understanding with acoustic cues (intonation, pitch, rhythm) for state-of-the-art accuracy.
        # AgentSession supplies the required VAD automatically.
        # See more at https://docs.livekit.io/agents/build/turns
        turn_handling=TurnHandlingOptions(
            turn_detection=inference.TurnDetector(),
        ),
        # allow the LLM to generate a response while waiting for the end of turn
        # See more at https://docs.livekit.io/agents/build/audio/#preemptive-generation
        preemptive_generation=True,
    )

    # Start the session, which initializes the voice pipeline and warms up the models
    await session.start(
        agent=Assistant(),
        room=ctx.room,
        room_options=room_io.RoomOptions(
            audio_input=room_io.AudioInputOptions(
                noise_cancellation=ai_coustics.audio_enhancement(
                    model=ai_coustics.EnhancerModel.QUAIL_VF_S
                ),
            ),
        ),
    )

    # # Add a virtual avatar to the session, if desired
    # # For other providers, see https://docs.livekit.io/agents/models/avatar/
    # avatar = anam.AvatarSession(
    #     persona_config=anam.PersonaConfig(
    #         name="...",
    #         avatarId="...",  # See https://docs.livekit.io/agents/models/avatar/plugins/anam
    #     ),
    # )
    # # Start the avatar and wait for it to join
    # await avatar.start(session, room=ctx.room)

    # Join the room and connect to the user
    await ctx.connect()

    # Answering machine detection (AMD): classify whether a real person,
    # voicemail, an IVR menu, or an unavailable line answered an outbound call.
    # See https://docs.livekit.io/telephony/features/answering-machine-detection/
    phone_number = os.getenv("SIP_PHONE_NUMBER")
    participant_identity = os.getenv("SIP_PARTICIPANT_IDENTITY")
    outbound_trunk_id = os.getenv("SIP_OUTBOUND_TRUNK_ID")

    # Focus the session on the callee before AMD starts so audio recognition
    # doesn't push frames from any pre-existing participant into AMD's pipeline
    if session.room_io and participant_identity:
        session.room_io.set_participant(participant_identity)

    # Initialize AMD before creating the SIP participant so detection is ready
    # before audio starts arriving. The detector pauses agent speech until a
    # result is available.
    async with AMD(
        session,
        participant_identity=participant_identity or NOT_GIVEN,
        # When the call is classified as machine-ivr, automatically start IVR
        # navigation. The navigator uses the agent's instructions (Goal 1) to
        # work through the menu toward a live human. This is the default.
        ivr_detection=True,
    ) as detector:
        # Start the outbound call inside the AMD scope to avoid audio loss
        if phone_number and outbound_trunk_id and participant_identity:
            logger.info(f"creating SIP participant for {participant_identity}")
            await ctx.api.sip.create_sip_participant(
                api.CreateSIPParticipantRequest(
                    room_name=ctx.room.name,
                    sip_trunk_id=outbound_trunk_id,
                    sip_call_to=phone_number,
                    participant_identity=participant_identity,
                    wait_until_answered=True,
                )
            )
            participant = await ctx.wait_for_participant(identity=participant_identity)
            logger.info(
                "participant joined",
                extra={
                    "identity": participant.identity,
                    "kind": participant.kind,
                    "audio_tracks_subscribed": [
                        pub.sid
                        for pub in participant.track_publications.values()
                        if pub.subscribed and pub.kind == rtc.TrackKind.KIND_AUDIO
                    ],
                },
            )

        # Run detection and log the category found
        result = await detector.execute()
        logger.info(
            f"AMD category: {result.category}",
            extra={"transcript": result.transcript},
        )

        if result.category in ("human", "uncertain"):
            # A person answered. Greet them and start sharing facts about space.
            logger.info("human answered, sharing facts about space")
            session.generate_reply(
                instructions=(
                    "Warmly greet the person who answered, then share one "
                    "fascinating, true fact about space."
                ),
            )
        elif result.category == "machine-ivr":
            # ivr_detection=True means the session is already navigating the
            # menu automatically, guided by the agent's instructions, to reach
            # a human. Once a human answers, the agent shares facts about space.
            logger.info("IVR menu detected, navigating to reach a human")
        elif result.category in ("machine-vm", "machine-unavailable"):
            # No human to talk to. End the call.
            logger.info(f"{result.category}: no human reachable, ending call")
            ctx.shutdown(reason=result.category)

    async def hangup():
        await ctx.api.room.delete_room(
            api.DeleteRoomRequest(room=ctx.room.name),
        )

    ctx.add_shutdown_callback(hangup)


if __name__ == "__main__":
    cli.run_app(server)

Hello

Thank you for providing us with an example. We replicated the exact same script with some prompt changes. And it went fine for a couple of tests (or so we thought). But then taking a closer look at the logs, we noticed that the LiveKit agent never waits for the IVR menu to complete before making its decision. It either presses an option it think is relevant as soon as it hears the digit, without waiting for the remaining options.

The audio also came through fine for the first few calls (although slightly delayed). But then on the last call, when we answered, there was about a 5 second silence and then the agent started speaking the same statement multiple times concurrently, overlapping itself. It sounded like broken audio. The same sentence, spoken 3 times, with each one cutting off the other every second. This was also mentioned in our original post.

When we looked at the logs for that call, we noticed the DTMF tool had been called 3 times with the same digit 0. And the last 2 attempts even having the same speech_id.

Our IVR and number-to-call-to are both on Twilio. We have tried SymmetricRTC enabled and disabled, both to no avail. Other than that, its the basic SIP Trunk setup required for LiveKit. We have also tested single-layer and multi-layer IVRs. The agent preemptively chooses the option before the IVR finishes.

It’s really annoying to deal with, because we cannot figure out what/where the issue is happening. We really appreciate your support with this.

I have attached the logs for that last call. Check the message below for our agent configuration and the testing script we use (the forum didn’t allow us to add more than 2 files). Please let me know if there’s anything else we can provide.

Thanks,
Diyan

logs.txt (36.2 KB)

Agent configuration and script for testing:

new_agent.py (12.5 KB)

test_call.py (4.7 KB)

I’m sure this could be addressed with an updated prompt, like I say I didn’t test the above agent with a real IVR tree - it looks like the prompt from the example DTMF agent is much better when it comes to describing IVR navigation: agents/examples/telephony/bank-ivr/ivr_navigator_agent.py at main · livekit/agents · GitHub.

I had another go and generated some tests for the IVR part:

test_amd_ivr.py (7.0 KB)

amd_ivr.py (11.5 KB)

Hello

Thank you once again for your response. We tried the new version you provided as well. And while the voice overlapping issue doesn’t seem to occur anymore, the other issue still persists.

The agent doesn’t wait for the IVR menu prompt to complete before making its decision. This often leads to incorrect choices. We need the agent to wait because every outbound call we’ll make will have different terminology for the departments we’re trying to get through to. Hence it is necessary that the agent first understand all the inputs.

And this time, another issue arises. Often, the agent will speak during the IVR prompt menu. Even though AMD is supposed to block the agent’s audio when the IVR menu is playing. This needs to be addressed as well, because it might play a role in causing the agent to use DTMF preemptively.

Looking at the GitHub repo and from LiveKit docs online, we noticed that instead of the default IVR navigation through AMD, there’s an option for a custom navigator agent. Would you recommend using a custom navigator and DTMF tool for our use case?

I understand that the conversation might be getting out of the scope of the forum. We really need this functionality to be fixed, and we’re happy to move this over to an email thread and discuss the enterprise plan as well.

Best,
Diyan

logs.txt (24.0 KB)

I am not really following the issue here.

Do you have a recording of a session that demonstrates the issue you are having? If you have Agent Insights enabled, you can share the link and be sure to click the “Share with LiveKit staff” option so we can access it.

If you can also write up what happened versus what you expected at the give audio timestamp.