Skip to content

feat(voice): outbound Calls API passthrough, call events & AMD - #78

Merged
ryanrouleau merged 16 commits into
mainfrom
feat/outbound-amd-call-events
Aug 4, 2026
Merged

feat(voice): outbound Calls API passthrough, call events & AMD#78
ryanrouleau merged 16 commits into
mainfrom
feat/outbound-amd-call-events

Conversation

@ryanrouleau

@ryanrouleau ryanrouleau commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. CallOptionsInitiateVoiceConversationOptions.call_options carries the calls.create parameters (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 a TypeError inside the Twilio client. to / from_ / twiml / url / application_sid are TAC-owned and refused.

  2. Per-callback handlerson_call_status / on_amd / on_recording, each with its own typed event (CallStatusEvent / AmdEvent / RecordingEvent), each independently optional. TACFastAPIServer serves one route per callback under voice_call_event_path (/status, /amd, /recording), so the route identifies the event — no payload sniffing. AmdEvent.is_machine and CallStatusEvent.is_unreached keep callers off mode-specific strings (unknown is not a machine, so a call is never hung up on a guess).

  3. end_call(call_sid) — hangs up (works on CallSid alone, 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 from on_amd to drop voicemail.

  4. 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 than end_call.

Callback URL auto-wiring. A URL derives from voice_public_domain + voice_call_event_path only when its handler is registered — otherwise TAC would point Twilio at a 404 (error 11200) on every outbound call for anyone not using TACFastAPIServer. Precedence, highest first: per-call call_options > VoiceChannelConfig.default_call_options > derived URL. Set the URLs in default_call_options for 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.

AMD requires both machine_detection and async_amd — enforced at construction. machine_detection turns detection on; without async_amd, Twilio returns AnsweredBy on the TwiML request, which inline TwiML can't receive. Either flag alone is a silent dead end.

Session correlation. ConversationSession.call_sid is populated on the Voice channel, matching the call events and the outbound result. Sessions are created on the caller's first prompt, so on_call_status / on_recording resolve one while on_amd under machine_detection="Enable" fires too early and usually won't — which is why end_call needs none.

Also: <Parameter> values are masked in the outbound TwiML debug log (they carry arbitrary custom_parameters — profile IDs, caller names), and voice_call_event_path is 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:

  • All three callbacks hit their auto-wired routes — AMD +4s, status +4s, recording +8s
  • answered_by=machine_startis_machineend_call hung up ~4s after answer, instead of monologuing at a machine
  • Recording delivered with a fetchable URL
  • Disposition reported completed, correctly not is_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

  • New feature

Checklist

  • Tests added/updated
  • Documentation updated (docstrings + example)
  • Tested E2E

SDK Parity

This is the Python SDK. The same surfaces should land in the TypeScript SDK.

  • Change is Python-specific (no TypeScript update needed)
  • TypeScript SDK PR created:

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
ryanrouleau force-pushed the feat/outbound-amd-call-events branch from e33a49e to e0c117c Compare July 6, 2026 23:55
ryanrouleau and others added 7 commits July 6, 2026 17:01
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>
Comment thread getting_started/examples/features/voice_call_events.py Outdated
Comment thread src/tac/channels/voice/channel.py Outdated
ryanrouleau and others added 7 commits August 3, 2026 12:22
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>
@ryanrouleau
ryanrouleau marked this pull request as ready for review August 3, 2026 18:15
Copilot AI review requested due to automatic review settings August 3, 2026 18:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_options passthrough to client.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 under voice_call_event_path.
  • Add ConversationSession.call_sid correlation + 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.

Comment thread tests/test_redaction.py Outdated
Comment thread tests/test_voice_channel.py
Comment thread src/tac/models/voice.py
Comment thread src/tac/utils/redaction.py
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>
@ryanrouleau
ryanrouleau merged commit 664600a into main Aug 4, 2026
16 checks passed
@ryanrouleau
ryanrouleau deleted the feat/outbound-amd-call-events branch August 4, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants