feat(voice): outbound Calls API passthrough, call events & AMD - #78
Merged
Conversation
Enable answering machine detection and call disposition reporting for outbound ConversationRelay calls: hang up on voicemail instead of monologuing at a machine, and observe which calls went unanswered / busy / failed (e.g. for outbound reconnect campaigns with retry logic). Three seams, matched to the shape of each surface: - call_options passthrough on InitiateVoiceConversationOptions: forwarded verbatim to calls.create (AMD, status_callback, record, timeout, SIP...). to/from/twiml are guarded. Callback URLs auto-wire from voice_public_domain + voice_call_event_path via setdefault, so an explicit URL always wins (passthrough preserved). status_callback wires whenever the domain is set; AMD/recording callbacks only when the dev opts into the feature. - on_call_event / handle_call_event + neutral CallEvent model: Twilio's three independent callback URLs (status, AMD, recording) all point at one route that TACFastAPIServer registers; handle_call_event classifies by payload fields and fires one handler discriminated by event.kind. - end_call(call_sid): hangs up via calls.update (works on CallSid alone, any mode) and best-effort tears down the CR session via a CallSid->conv_id map. Identifier plumbing so the surfaces correlate: CallSid is the public key everywhere. ConversationSession.call_sid is now populated on the voice channel (equals conversation_id in relay-only mode, the Twilio SID in orchestrator mode), matching CallEvent.call_sid and the outbound result. New config: voice_call_event_path / TWILIO_VOICE_CALL_EVENT_PATH. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ryanrouleau
force-pushed
the
feat/outbound-amd-call-events
branch
from
July 6, 2026 23:55
e33a49e to
e0c117c
Compare
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… fields Verified against Twilio's call-resource docs: - A completed statusCallback also posts RecordingSid/RecordingUrl, so classify recording on RecordingStatus (posted only by the recording callback) rather than on the presence of a recording SID — otherwise a completed call event with a recording would misclassify as kind="recording". - Surface the commonly-needed status/recording fields as typed attributes: call_duration, sip_response_code, recording_duration. (Everything remains available via event.raw.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the unified CallEvent/on_call_event surface with one typed event and handler per Twilio callback, matching the shape of the Calls API: - CallStatusEvent / on_status / handle_status_event - AmdEvent / on_amd / handle_amd_event - RecordingEvent / on_recording / handle_recording_event Each handler is independently optional and its event carries only its own fields (no flat 11-optional model). TACFastAPIServer serves one route per callback under voice_call_event_path (/status, /amd, /recording), so the route the webhook arrives on identifies the event — no ?kind= query tag and no payload-field guessing. The base path is trailing-slash normalized. The Voice API is stable, so mirroring its three callbacks 1:1 is simpler and more discoverable than a discriminated union: self-wirers see named routes and map them to Twilio's three callback params directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"status" is overloaded in the voice model: ConversationRelayCallbackPayload already carries both CallStatus and SessionStatus (the session-ended callback). Name the Calls-API disposition handler on_call_status (matching its CallStatusEvent type and Twilio's CallStatus field) so it doesn't read as the ConversationRelay session callback. on_amd/on_recording stay terse — no such collision. Also renames handle_status_event -> handle_call_status_event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cut the internal "seam" framing and the comments that restated the code; keep only the comments that flag Twilio quirks (mixed str/bool call_options types, async_amd="true" required for the AMD callback). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ryanrishi
reviewed
Jul 14, 2026
The AMD example passed only async_amd, which doesn't enable detection — Twilio needs machine_detection too, so no AMD event would ever arrive. Both flags are now required together: without async_amd, Twilio returns AnsweredBy on the TwiML request, which inline TwiML can't receive, so either flag alone is a silent dead end. - call_options is a typed CallOptions model. Field types follow the Twilio SDK signature (record: bool, async_amd: str) and normalize that inconsistency. Unknown keys are rejected against the signature of CallList.create, which takes no **kwargs — so a typo fails at construction rather than as a TypeError inside the client. - Callback URLs auto-wire only when the matching handler is registered. Previously any deployment with voice_public_domain set advertised status_callback, pointing Twilio at a 404 (error 11200) on every outbound call for anyone not using TACFastAPIServer. - TACConfig.call_event_path/call_event_url are the single source of truth for the three route suffixes. The channel builds callback URLs from them and the server registers routes at them, so the URL handed to Twilio can't drift from the route serving it. - Replace the call-event route factory with three decorated routes, matching the idiom used by every other route in _register_routes. - Add AmdEvent.is_machine and CallStatusEvent.is_unreached so callers stop matching mode-specific strings. "unknown" is not a machine, so a call is never hung up on a guess. - end_call returns bool instead of discarding the hangup outcome. - Mask <Parameter> values in the outbound TwiML debug log; they carry arbitrary custom_parameters (profile IDs, caller names). - Validate voice_call_event_path, the one path that isn't registered literally: leading slash, and no derived sub-path colliding with another POST route. - Correct the status-event docs: Twilio sends only the terminal event unless status_callback_event is set. It still covers every disposition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Call events were the only derived-URL family without a channel-wide
override layer. websocket_url and action_url both resolve through
default_twiml_options, so a deployment with routes at non-default paths
sets them once at channel construction; call events had only a per-call
override, which is a poor fit for what is really a global routing choice.
Custom servers were the case that exposed it. TAC derives callback URLs
from voice_public_domain + voice_call_event_path, which assumes
TACFastAPIServer's routes — so a Flask/Django deployment that registered
a handler got a URL it wasn't serving and a 404 (error 11200) per call.
VoiceChannel(tac, config=VoiceChannelConfig(
default_call_options=CallOptions(
async_amd_status_callback="https://me.com/amd-hook",
),
))
Precedence, matching default_twiml_options: per-call call_options >
default_call_options > derived URLs. Layers merge per field via
model_fields_set, and the merged result is re-validated so a combination
only reachable by layering — per-call clearing machine_detection while
the default set async_amd — fails instead of reaching Twilio.
Deriving a URL still requires the matching handler. That deviates from
websocket_url/action_url, which derive unconditionally, and the docstring
now says why: those are load-bearing and fail loudly on the first call,
whereas an unwanted call-event URL fails as silent 11200 alerts for a
feature nobody asked for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Call events carry only the CallSid, but every session-facing method (send_response, get_websocket) is keyed by conversation id. In ConversationRelay-only mode those are the same string, so it works by accident; in orchestrator mode conv_id is the Orchestrator conversation id and the only bridge was the private _conv_id_for_call_sid. That left end_call as the only thing an AMD handler could do — reaching the live agent, reading the profile, or marking the session meant touching channel._conversations. Named for ConversationSession rather than the shorter get_session_* because "session" is already taken here: session_manager deals in SessionState, and both types appear four lines apart in _initialize_conversation. Same disambiguation as on_status -> on_call_status. Returns the session rather than the id, so a handler gets metadata and profile too and conversation_id comes along for free. Net zero methods: end_call now uses it and _conv_id_for_call_sid is gone, along with its redundant `conv_id in self._conversations` guard — the session came from that dict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
E2E against a real voicemail (machine_detection="Enable") showed the session lookup returning None from on_amd, which is correct and structural: sessions are created on the caller's first prompt, and AMD is built to resolve before anyone speaks. The docstring, field description and example all pointed at that case. - get_conversation_session_by_call_sid: reframe around out-of-band code holding a CallSid mid-conversation (dashboard route, operator action), and say plainly that on_amd under "Enable" won't have a session — hang up with end_call, which needs none. Test renamed off the AMD framing; added one pinning the no-first-prompt case. - Example: drop the claim that these handlers fire for inbound calls. machine_detection and record are calls.create parameters, so AMD and recording have no inbound equivalent; on_call_status does work inbound but its URL isn't auto-wired. Name the accessor in a comment rather than calling it, so the example doesn't ship a lookup that always prints None. - _twilio_call_create_params: return empty (permissive) when the SDK signature has **kwargs. The docstring promised permissive-on-failure, but a **kwargs signature isn't a failure — the named params would silently become the accepted set and reject every real extra. - _merge_call_options: drop the redundant __pydantic_extra__ update; model_fields_set already covers extras when extra="allow". - Re-export CallOptions from tac.channels.voice, so configuring VoiceChannelConfig.default_call_options is a single import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Naming the accessor in a comment left it undiscoverable — a dev reading the example has no reason to go looking for it. Call it and print the result instead, with the constraint stated: usually None here, because sessions start on the caller's first prompt and AMD resolves before a machine has said anything. Reachable from then until the call ends. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two lines: what you get off the conversation session, and why it's usually None at AMD time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
on_amd was the worst of the three callbacks for it, not the best. Session teardown is mode-dependent: _cleanup_connection keeps the session in orchestrator mode until Conversation Orchestrator's CLOSED webhook, and handle_conversation_relay_callback only ends it when orchestrator mode is off. So in orchestrator mode the session outlives the call, and on_call_status / on_recording resolve while on_amd — firing before the caller's first prompt — usually doesn't. Document both ends of the window on the accessor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the Voice channel’s outbound calling support by forwarding Twilio Calls API parameters via typed call_options, adding first-class call-event webhooks (status / AMD / recording) with per-event handlers and FastAPI routes, and enabling best-effort call hangup + session cleanup via end_call(call_sid). It also adds TwiML log redaction for <Parameter value="..."> to reduce accidental logging of developer-supplied data.
Changes:
- Add
CallOptions+InitiateVoiceConversationOptions.call_optionspassthrough toclient.calls.create(...), including auto-wiring of callback URLs when handlers are registered. - Add call event models (
CallStatusEvent,AmdEvent,RecordingEvent) + VoiceChannel handlers (on_call_status,on_amd,on_recording) and FastAPI routes undervoice_call_event_path. - Add
ConversationSession.call_sidcorrelation +VoiceChannel.end_call(call_sid)and a getting-started example for call events.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_voice_channel.py | Adds unit tests for call-event paths, event models/predicates, handlers, end_call, and call-sid session lookup. |
| tests/test_server.py | Adds FastAPI server validation and routing tests for call-event endpoints. |
| tests/test_redaction.py | Adds tests for TwiML <Parameter> value redaction. |
| tests/test_outbound.py | Adds outbound call-option passthrough + auto-wiring + default-call-options layering tests. |
| src/tac/utils/redaction.py | Introduces redact_twiml_parameters() for masking <Parameter value="..."> in TwiML logs. |
| src/tac/server/fastapi_server.py | Registers 3 call-event POST routes and validates voice_call_event_path expansion/collisions. |
| src/tac/models/voice.py | Adds typed call-event webhook models and parsing with from_form(...). |
| src/tac/models/session.py | Adds ConversationSession.call_sid for correlating calls and events. |
| src/tac/models/outbound.py | Adds CallOptions, SDK-signature validation, AMD invariants, and serialization behavior. |
| src/tac/models/init.py | Re-exports CallOptions and call-event models at package level. |
| src/tac/core/config.py | Adds voice_call_event_path, call_event_path/url, and CALL_EVENT_KINDS. |
| src/tac/channels/voice/config.py | Adds default_call_options and handler type aliases for call events. |
| src/tac/channels/voice/channel.py | Implements handler registration, event dispatch, auto-wiring, TwiML redaction logging, end_call, and session lookup by CallSid. |
| src/tac/channels/voice/init.py | Exposes call-event types/handlers and CallOptions from the voice channel package. |
| getting_started/README.md | Documents the new voice_call_events.py example. |
| getting_started/examples/features/voice_call_events.py | Adds an end-to-end example demonstrating status/AMD/recording handlers + voicemail hangup. |
| CLAUDE.md | Updates architecture notes to include call-event routing/scaling implications. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Addresses the Copilot review on #78. - _CallEventBase.call_sid is required and non-blank. It defaulted to "", so a payload without CallSid reached handlers as an empty SID and the example's end_call("") became a Twilio 404 logged as a hangup failure. Missing or blank now raises, which the route already turns into a 400. - redact_twiml_parameters handles value='...' as well as value="...", via a backreferenced quote group so a quote of the other style inside the value can't end the match early. TAC's TwiML comes from the Twilio SDK, which always double-quotes, but this takes a plain string and shouldn't rely on that. Renamed the test that claimed single-quote coverage without asserting it, and added the assertions. - test_call_event_kinds_covers_every_kind: the server stopped iterating CALL_EVENT_KINDS to register routes in abba7a8 (three explicit decorators now); it only validates paths with it. Docstring said otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ryanrishi
approved these changes
Aug 3, 2026
xinghaohuang91
approved these changes
Aug 4, 2026
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Enables AMD and recording on outbound ConversationRelay calls, and reports call disposition (unanswered / busy / failed) — for outbound chase campaigns that need to hang up on voicemail and know which calls went unreached.
Four surfaces:
CallOptions—InitiateVoiceConversationOptions.call_optionscarries thecalls.createparameters (machine_detection,async_amd,record,timeout, SIP auth, …). Typed for the ones outbound ConversationRelay reaches for, open for the rest. Unknown keys are rejected at construction against the installed SDK's signature, so a typo fails there rather than as aTypeErrorinside the Twilio client.to/from_/twiml/url/application_sidare TAC-owned and refused.Per-callback handlers —
on_call_status/on_amd/on_recording, each with its own typed event (CallStatusEvent/AmdEvent/RecordingEvent), each independently optional.TACFastAPIServerserves one route per callback undervoice_call_event_path(/status,/amd,/recording), so the route identifies the event — no payload sniffing.AmdEvent.is_machineandCallStatusEvent.is_unreachedkeep callers off mode-specific strings (unknownis not a machine, so a call is never hung up on a guess).end_call(call_sid)— hangs up (works onCallSidalone, in any mode, before a session exists) and best-effort tears down the ConversationRelay session. Returns whether Twilio accepted it; never raises, since hanging up an already-ended call is routine. Call it fromon_amdto drop voicemail.get_conversation_session_by_call_sid(call_sid)— call events carry only the CallSid, but the session-facing methods (send_response,get_websocket) are keyed by conversation id, which is the Orchestrator conversation id in orchestrator mode. This crosses that gap for handlers that need more thanend_call.Callback URL auto-wiring. A URL derives from
voice_public_domain+voice_call_event_pathonly when its handler is registered — otherwise TAC would point Twilio at a 404 (error 11200) on every outbound call for anyone not usingTACFastAPIServer. Precedence, highest first: per-callcall_options>VoiceChannelConfig.default_call_options> derived URL. Set the URLs indefault_call_optionsfor a custom server or non-default routes.TACConfig.call_event_path()is the single source for both the URL handed to Twilio and the route serving it, so they can't drift.Session correlation.
ConversationSession.call_sidis populated on the Voice channel, matching the call events and the outbound result. Sessions are created on the caller's first prompt, soon_call_status/on_recordingresolve one whileon_amdundermachine_detection="Enable"fires too early and usually won't — which is whyend_callneeds none.Also:
<Parameter>values are masked in the outbound TwiML debug log (they carry arbitrarycustom_parameters— profile IDs, caller names), andvoice_call_event_pathis validated at server construction for a leading slash and for sub-paths colliding with another POST route.New config:
voice_call_event_path/TWILIO_VOICE_CALL_EVENT_PATH.Tested E2E
Outbound call to a mobile that went to voicemail; orchestrator mode, ngrok,
machine_detection="Enable"+record=True. Timings relative to answer:answered_by=machine_start→is_machine→end_callhung up ~4s after answer, instead of monologuing at a machinecompleted, correctly notis_unreached— the call was answered, just by a machine. Counting voicemails needs the AMD event; the status disposition alone can't distinguish it from a human.Not yet exercised: the human-answer path and ring-out (
is_unreached).Type of Change
Checklist
SDK Parity
This is the Python SDK. The same surfaces should land in the TypeScript SDK.