AI Voice Agent Pilot vs Production: Lessons From Real Outbound Calls A developer running a self-hosted Italian-language dental voice agent on Asterisk, fed by a ViciDial press-1 campaign, documented the failures that appeared only after real outbound calls began, including a P0 bug where a shell AGI script misparsed Asterisk's 'agi_key: value' protocol lines and died before staging any session, leaving agent, direction and provider fields null. The dialplan also passed a hard-coded UUID per language to AudioSocket(), so every Italian call reached the gateway with the same identifier and conversations could only be linked to ViciDial leads via a timestamp join. The fix was a Python AGI that parses the environment as a protocol and generates a uuid.uuid4() per call within an 8-second alarm deadline. A self-hosted AI phone agent can pass every demo and still fail on real outbound traffic. This is a case study of an Italian-language dental agent on Asterisk, fed by a ViciDial press-1 campaign. It covers what broke once real callers reached it: call identity, latency under load, answering machine detection, an overflowing context window, safety guards, and retrieval. The measured numbers come from the system's own traces, database rows, CDRs or test output. Where something is an observation and not a controlled result, I say so. Configuration snapshot, September 2026: Asterisk 20.6 on the agent host; ViciDial on Asterisk 18.26.4-vici ViciDial's patched build on the dialer; AudioSocket; Silero VAD 6.2; faster-whisper 1.2.1 large-v3 ; Ollama 0.30.8 with llama3.1:8b num ctx 4096 ; Qdrant 1.18; one 20 GB workstation GPU. Italian TTS was a hosted streaming service, with local Kokoro/Piper code as a fallback. Earlier experiments ran on earlier spec versions of the same stack. php ViciDial press-1 campaign - SIP - Asterisk agent host - AGI stage a session - AudioSocket 8 kHz 16-bit PCM over TCP - Python gateway: Silero VAD - faster-whisper - LLM Ollama - TTS - audio back over the same socket ViciDial dials the leads. The callee hears a short recorded message and presses 1, and ViciDial sends the call to the agent host. The agent speaks Italian and has one job: find out whether the caller has a dental need, then get them to send two photos of their teeth over WhatsApp so a human can prepare a free quote. Basic demos sounded good. Targeted tests and real-call reviews did not. The checklist at the end sums up what we learned. About two weeks after real press-1 calls started, we ran a read-only production-readiness audit. Its first line said: "operational pilot, not ready for an unattended production service." The GPU, models and health checks were fine. What failed was the integration around them. The worst finding was a P0 in the first step every call runs. The dialplan called a shell AGI script before AudioSocket, and the script read its AGI environment like this: while read -r line; do -z "$line" && break export "$line" done Asterisk sends AGI variables as agi request: something , with a colon and a space. That is not a shell assignment, and you can reproduce the failure in one line 123.4 is a made-up uniqueid : /bin/sh -c 'while read -r line; do -z "$line" && break; export "$line"; done; echo REACHED' <<'EOF' agi request: prepare.sh agi uniqueid: 123.4 EOF /bin/sh: 1: export: agi request: prepare.sh: bad variable name exit=2 The script died before it staged anything. In the gateway's session prepared events, agent, direction and provider reference were all null. The dialplan also passed a hard-coded UUID per language to AudioSocket , so every Italian call reached the gateway with the same 3333... UUID. The gateway used that UUID to pick the language line, not to identify the call. Linking a conversation back to its ViciDial lead took a timestamp join. A demo call doesn't show this, because the agent still answers and talks. The fix was a small Python AGI that treats AGI input as a protocol, never as shell code: python import re def read environment stream : """Read AGI 'agi key: value' lines until the blank line.""" result = {} for line in stream: line = line.rstrip '\r\n' if not line: break key, sep, value = line.partition ':' if sep and re.fullmatch r'agi a-z0-9 +', key : result key = value.lstrip return result After reading the environment, the script sets an 8-second signal.alarm . Inside that deadline it reads its config, checks the gateway readiness URL when one is configured , logs in, generates uuid.uuid4 , stages the session and returns the IDs with SET VARIABLE . The deadline does not cover the initial environment read or the final SET VARIABLE FLOW STAGE OK 0 on the error path. If you copy this design, put those under a timeout too. The dialplan checks the result. This excerpt is from the deployed context the channel is answered earlier, at Answer : js same = n,Set FLOW STAGE OK=0 same = n,AGI prepare.sh,${EXTEN} same = n,GotoIf $ "${FLOW STAGE OK}" = "1" ?flow unavailable same = n,ExecIf $ "${CALLSESSIONID}" = "" ?Set CDR userfield =${CALLSESSIONID} same = n,ExecIf $ "${FLOW VICI CID}" = "" ?Set CDR userfield =${CDR userfield }|vici:${FLOW VICI CID} same = n gw ,AudioSocket ${FLOW CALL UUID},${FLOW AUDIO GW} ... same = n flow unavailable ,Playback custom/agent-unavailable same = n,Hangup 41 As deployed, the failure path plays an "unavailable" message and hangs up. That fails closed, but no human or callback destination exists yet. On the dialer side, ViciDial adds its caller code as a SIP header SIPAddHeader X-vici-cid: ${CALLERID name } , and the agent host reads it with PJSIP HEADER read,X-vici-cid . For one real call after the fix, the IDs line up like this values shortened : | Where | Field | Value | |---|---|---| | ViciDial vicidial log extended | caller code / uniqueid / lead id | V917…174 / …793.36 / …174 | | Agent-host CDR | userfield | \ | | Agent-host CDR | {% raw %} uniqueid | …818.512 | | Agent-host CDR | lastdata AudioSocket UUID | d26f…6beb | | Gateway session prepared | callSessionId , asteriskUuid , externalProviderRef |