Skip to content

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

Draft
ryanrouleau wants to merge 8 commits into
mainfrom
feat/outbound-amd-call-events
Draft

feat(voice): outbound Calls API passthrough, call events & AMD#78
ryanrouleau wants to merge 8 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/retry campaigns that need to hang up on voicemail and know which calls went unreached.

Three seams:

  1. call_options passthroughInitiateVoiceConversationOptions.call_options forwards verbatim to calls.create (machine_detection, async_amd, record, timeout, SIP auth, …). Twilio validates it; to/from/twiml are reserved.
  2. Per-callback handlerson_status / on_amd / on_recording, each with its own typed event (CallStatusEvent / AmdEvent / RecordingEvent) and 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 guessing.
  3. end_call(call_sid) — hangs up (works on CallSid alone) and best-effort tears down the ConversationRelay session; call it from on_amd to drop voicemail.

Callback URLs auto-wire from voice_public_domain + voice_call_event_path: status whenever the domain is set, amd/recording only when the caller opts into that feature (async_amd="true" / record truthy). An explicit URL in call_options always wins. ConversationSession.call_sid is populated so events correlate with on_message_ready.

Note: AMD events require async_amd="true" (sync machine_detection returns via the TwiML leg, not the async callback).

New config: voice_call_event_path / TWILIO_VOICE_CALL_EVENT_PATH.

Type of Change

  • New feature

Checklist

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

SDK Parity

This is the Python SDK. The same seams 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 on lines +69 to +70
"async_amd": "true", # required for the AMD callback (sync AMD won't fire it)
"record": True,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does using True instead of "true" work?

Suggested change
"async_amd": "true", # required for the AMD callback (sync AMD won't fire it)
"record": True,
"async_amd": True, # required for the AMD callback (sync AMD won't fire it)
"record": True,

Comment on lines +52 to +59
Twilio types these params inconsistently across its docs (``async_amd`` as
the string ``"true"``, ``record`` as a bool), and call_options is a free-form
passthrough, so accept both: real booleans and the string ``"true"`` (any
case). Everything else — ``False``, ``"false"``, ``None``, absent — is off.
"""
if isinstance(value, str):
return value.strip().lower() == "true"
return bool(value)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh I see, is having both True and "true" in the example to demonstrate that you can pass either?

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.

2 participants