Header Logo

How to Handle Call Transfers, DTMF, and Fallbacks in a Go Voice Agent

How to Handle Call Transfers, DTMF, and Fallbacks in a Go Voice Agent

Go voice agent guide to call transfers, DTMF handling, idempotent handoffs, and deterministic fallback state machines.

Introduction


Call transfers, DTMF, and fallback logic are where a voice agent stops being a demo and starts behaving like a real system. If you already have speech-to-text, an LLM, and text-to-speech wired together, the remaining hard part is operational: how do you hand a caller to a human, collect keypad input reliably, and avoid dead ends when a downstream service fails?


This post walks through the mechanics from the perspective of a Go voice agent. By the end, you should be able to design a transfer path that preserves call state, capture and interpret DTMF without confusing it with speech, and build sensible fallback behavior when your agent, telephony provider, or avatar layer is under stress.


Start by modeling the call as a state machine, not a single loop


The most common mistake is to treat a voice agent as “listen, think, speak” in a tight loop. That works until the user says “transfer me to billing,” presses 3 halfway through a sentence, or the downstream contact center is unavailable.


Instead, model the call as a small state machine with explicit transitions:


  • Conversation — normal assistant dialog.

  • Collecting input — waiting for DTMF or a short utterance.

  • Transferring — stopping agent output and initiating handoff.

  • Fallback — routing to a fallback queue, voicemail, or safe apology path.


That structure matters because each state has different audio and timing behavior. For example, while collecting DTMF, you usually want to suppress barge-in from your LLM, but still keep the audio stream open. During transfer, you want to stop speaking immediately, flush any buffered audio, and ensure the callee hears the final handoff summary, not half a sentence.


Handling call transfers cleanly


A transfer is not just a “dial another number” action. You are moving an active media session between endpoints, and the user experience depends on what the caller hears during that transition.


There are three practical patterns:


  1. Blind transfer — you connect the caller directly to the destination and drop out.

  2. Warm transfer — you briefly join the destination, announce the caller, then bridge them together.

  3. Assisted transfer — you keep the agent in the loop until a human accepts, then hand off context.


For a voice agent, warm or assisted transfers are usually better. They let you summarize the conversation, expose intent, and avoid forcing the caller to repeat themselves. The key technical detail is to freeze the agent’s output pipeline before the bridge changes. If the TTS engine is still speaking while the transfer is being established, you can end up with clipped audio or overlapping prompts on the human side.


Transfer flow: practical sequence


A robust transfer flow looks like this:


  1. Detect the transfer intent or receive a control signal from your orchestration layer.

  2. Stop sending new assistant turns.

  3. Drain or cancel any in-flight TTS synthesis.

  4. Generate a concise transfer summary from conversation state.

  5. Initiate the PSTN/SIP/agent bridge to the destination.

  6. Play the summary if the handoff supports it, or attach it as metadata if your telephony stack supports structured transfer context.


Two gotchas matter here:


  • Do not rely on the live transcript alone. Keep a structured conversation state with fields like intent, account ID, issue category, and verification status.

  • Make transfer idempotent. Retries happen. If your transfer function is called twice, the second attempt should be a no-op or a safe revalidation, not a second bridge.


In Go, that often means wrapping transfer logic in a context-aware function that can be canceled cleanly:


func transferCall(ctx context.Context, callID, destination string, summary map[string]any) error {
}
func transferCall(ctx context.Context, callID, destination string, summary map[string]any) error {
}
func transferCall(ctx context.Context, callID, destination string, summary map[string]any) error {
}


DTMF: treat keypad input as a separate input channel


DTMF is not speech. It should be modeled as a control channel with its own timing and confidence rules. If you blend keypad digits into the same stream as ASR text, you eventually get edge cases like “press 1” being interpreted as a spoken intent instead of a menu selection.


At a minimum, make these decisions explicit:


  • When DTMF is active — only during menus, identity verification, or routing prompts.

  • How long you wait — e.g. 5–10 seconds before reprompting.

  • How many digits you expect — single digit menus, fixed-length account numbers, or variable-length with terminators.

  • What counts as cancellation — a timeout, star key, or a spoken interruption.


For voice agents, the important part is deciding precedence. If the user presses a key while the model is also generating an answer, the DTMF event usually wins. You should stop the current response, acknowledge the selection, and move the state machine forward. That keeps the system feeling deterministic instead of “chatty.”


Parsing and validating DTMF


Most telephony and media stacks deliver DTMF as discrete events, not as audio. That means you should validate the sequence before acting on it. Examples:


  • Single-digit menu: accept only one digit from a known set.

  • PIN entry: require exact length and a retry limit.

  • Account lookup: accept digits plus a terminator key, then normalize the string.


A good pattern is to isolate parsing from business logic:


type DTMFEvent struct {

}
type DTMFEvent struct {

}
type DTMFEvent struct {

}


Then your agent logic can stay simple:


switch selection {
}
switch selection {
}
switch selection {
}


If you need to support both speech and DTMF in the same turn, define a precedence order up front. In most support flows, keypad input should override any ambiguous ASR result. For accessibility and reliability, that’s usually the least surprising behavior.


Fallbacks: design for the failure you will actually see


Fallbacks are not just “if the LLM errors, say sorry.” In production, failures happen at multiple layers:


  • ASR partials become unstable or timeout.

  • The LLM returns a malformed tool call.

  • TTS stalls or exceeds latency budget.

  • The transfer target is busy or unreachable.

  • The user is silent, speaking over the prompt, or entering invalid digits repeatedly.


Good fallback behavior is specific to the failure mode. A few examples:


  • No speech detected — reprompt once, then offer DTMF or transfer.

  • Invalid DTMF — repeat the menu and explain accepted digits.

  • Transfer failed — apologize, keep the caller on the line, and route to a known fallback queue or voicemail.

  • Model failure — use a static safe response and a handoff path, not another model call in a loop.


The most important design rule is to avoid recursive fallback. If the fallback path depends on the same service that just failed, you can end up in a loop. Keep one or two deterministic responses available locally, and make the emergency path boring.


How this fits with Protoface


When you add a realtime avatar to a voice agent, the call-control logic above still applies; the avatar should follow the agent state, not drive it. The usual integration point is the LiveKit Agents plugin, which lets a Protoface avatar ride along with the voice agent so the caller sees a synchronized talking face while the call is in the conversation state. The same state machine can pause the avatar during transfer, mute it during DTMF collection, or switch to a neutral fallback expression when the conversation degrades.


If you are using a LiveKit-based stack, the plugin repo is the place to look for current examples: https://github.com/protoface-ai/protoface-plugin-pipecat and the Pipecat integration guide is here: https://docs.pipecat.ai/api-reference/server/services/video/protoface. For Protoface-specific setup details and session/agent lifecycle behavior, keep the docs open while wiring your transfer and fallback hooks.


Example orchestration pattern in Go


Here is a simplified control loop that shows how the pieces fit together. The exact transport and event types will vary by telephony stack, but the state handling is the part worth keeping:


type State string

}
type State string

}
type State string

}


This pattern keeps control flow explicit. It also makes testing much easier: you can simulate sequences like “user presses 2 during a long answer,” “transfer target busy,” or “no input after prompt” without needing a live call every time.


Conclusion


Reliable voice agents are mostly about control, not generation. Treat transfers, DTMF, and fallbacks as first-class state transitions; separate keypad input from speech; and keep a deterministic recovery path for failures you can predict. If you do that well, the agent feels much more stable under real-world conditions, and the avatar layer can stay visually synchronized with the actual call state instead of guessing.


For implementation details, event shapes, and current integration examples, start with the docs and the relevant plugin or SDK repo for your stack. If you need to adapt this pattern to a specific telephony provider or agent framework, the same principles still apply: explicit state, cancelable audio, idempotent handoffs, and boring fallbacks.

Add a face to your AI.

No credit card needed.

Add a face to your AI.

No credit card needed.

Add a face to your AI.

No credit card needed.