feat(voice call): add Telnyx Media Streaming for voice-call realtime#81024
Conversation
|
Codex review: needs maintainer review before merge. Summary Reproducibility: not applicable. as a feature PR rather than a bug report. Source inspection confirms current main lacks Telnyx realtime support, and the PR body supplies live after-fix logs for the proposed behavior. Real behavior proof Next step before merge Security Review detailsBest possible solution: Choose one canonical Telnyx realtime lifecycle design, land it with the adapter/payload coverage and live proof, then close or supersede the competing PR cleanly. Do we have a high-confidence way to reproduce the issue? Not applicable as a feature PR rather than a bug report. Source inspection confirms current main lacks Telnyx realtime support, and the PR body supplies live after-fix logs for the proposed behavior. Is this the best way to solve the issue? Unclear as the final product direction. The inline dial/answer implementation is plausible and aligns with documented Telnyx surfaces, but maintainers should choose between this PR and #79575 before merging either design. What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 094f6d83c727. |
|
Yes, I tested this and it works locally. 👍 |
|
Cross-linking from the parallel Telnyx Media Streaming PR: #81024. The implementations took different paths on carrier-side kickoff: this PR uses
Concrete limitations the
|
Wires bidirectional PCMU WebSocket audio for Telnyx so realtime providers (OpenAI Realtime, etc.) can drive Telnyx calls the same way they drive Twilio. Telnyx attaches Media Streaming at dial time and answer-action time per the documented canonical patterns (no actions/streaming_start call needed). New StreamFrameAdapter abstraction owns provider-shaped frame parsing and outbound serialization, so realtime-handler.ts stays carrier-agnostic. RealtimeAudioPacer is generalized to accept any serializer. The provider-twilio realtime gate widens to accept telnyx. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Adds tests across the touched surface: StreamFrameAdapter for both Twilio (with streamSid) and Telnyx (without), the generalized RealtimeAudioPacer carrying both envelopes, Telnyx provider dial-time and answer-action streaming params with the call.streaming.failed -> call.error mapping, manager streamSessionIssuer wiring for Telnyx outbound, and the widened realtime + telnyx config gate. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two parsing bugs caught by Codex review on the Telnyx Media Streaming
PR:
Lifecycle webhook event names had a stray `call.` prefix that never
matched Telnyx's documented event types. Telnyx surfaces stream
lifecycle as `streaming.started` and `streaming.stopped` (no prefix);
stream errors arrive as `{event:"error"}` JSON frames over the
WebSocket, not as carrier webhooks. Drop the bogus
`call.streaming.failed` case from the webhook parser and add a new
`error` frame kind to the StreamFrameAdapter union so the realtime
handler can log failures instead of silently dropping them.
Telnyx WebSocket frames carry `stream_id` at the top level of the
envelope and `call_control_id` inside the `start` object; the
Telnyx adapter was reading `start.stream_id` (always undefined) and
defaulting `providerCallId` to the constructor-supplied value
regardless of what the carrier sent. Read both fields from the
documented locations and fall back to the constructor providerCallId
only when the carrier frame omits them.
Tests updated to reflect the carrier-documented frame shapes; new
fixture covers `{event:"error"}` round-trip through the adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
5a52fdd to
0d75611
Compare
|
Landed via rebase onto main.
Thanks @dynamite-bud! Re-review progress:
|
|
Additional real-world proof after maintainer cleanup: We also tested this end-to-end with a real Telnyx outbound call against the rebased PR branch. The call connected, Telnyx media streamed through the local OpenClaw voice-call runtime over a temporary public webhook tunnel, and the OpenAI realtime voice agent responded audibly during the live call. After the tunnel was kept open for the full session, the interaction behaved as expected: inbound speech was transcribed, the realtime bridge stayed active, and the agent response was heard on the phone. No phone numbers, credentials, tunnel URLs, or transcript contents are included here. |
|
Thank you very much @obviyus |
Summary
Adds Telnyx Media Streaming (PCMU 8 kHz μ-law) support to
@openclaw/voice-callso realtime voice providers (OpenAI Realtime, etc.) can drive Telnyx calls the same way they already drive Twilio calls. Until now,realtime.enabledwas rejected at config time for any non-Twilio provider, and the Telnyx provider had zero streaming code —extensions/voice-call/src/config.tsgated realtime to Twilio explicitly.This closes that parity gap for the realtime path.
Why
The voice-call plugin's realtime bridge in
extensions/voice-call/src/webhook/realtime-handler.tsis provider-agnostic in shape (it consumes aRealtimeVoiceProviderPluginfromopenclaw/plugin-sdk/realtime-voicefor the model side), but its WebSocket frame parsing and outbound serialization were inlined and Twilio-shaped. The Twilio provider attaches Media Streams via TwiML; Telnyx had no equivalent kickoff and no frame plumbing. Result: anyone configuringprovider: "telnyx"withrealtime.enabled: truehit a config-time error and never got past the gate.Closes the gap by:
StreamFrameAdapterinterface with one implementation per carrier (Twilio retainsstreamSid; Telnyx omits it).actions/streaming_startcall needed).provider === "twilio"to allowtelnyxas well.PCMU-only for the first cut. L16 / wideband is out of scope (libsamplerate-js resampling is a separate addition).
Design decisions (locked at plan time)
audio/pcmunatively perextensions/openai/realtime-voice-provider.ts:198). No resampling on the hot path.POST /v2/callspayload includesstream_url,stream_codec: "PCMU",stream_bidirectional_*fields,stream_auth_token), and answer-action inline for inbound (POST /calls/{id}/actions/answerincludes the same fields). Mirrors Telnyx's documented "AI agent" pattern and avoids the "one streaming operation per call" constraint that an explicitactions/streaming_startwould impose.StreamFrameAdapterinextensions/voice-call/src/webhook/stream-frame-adapter.ts. Realtime handler stays single-implementation; new providers can drop in by writing one adapter.issueStreamSessionmethod; manager calls it via a thinstreamSessionIssuerhook (Twilio path is unchanged since Twilio learns the URL from TwiML).streaming.enabled) for Telnyx is a follow-up; the new frame adapter makes it a smaller diff.Files of interest
extensions/voice-call/src/webhook/stream-frame-adapter.ts(new) —StreamFrameunion +StreamFrameAdapterinterface + Twilio/Telnyx implementationsextensions/voice-call/src/webhook/realtime-handler.ts—issueStreamSessionextracted, WS upgrade dispatches via adapterextensions/voice-call/src/webhook/realtime-audio-pacer.ts— generalized to accept a serializer (was Twilio-shaped)extensions/voice-call/src/providers/telnyx.ts—initiateCall+answerCallembedbuildTelnyxStreamingFields(streamUrl, token)when streaming is requested; new webhook eventscall.streaming.failed→ non-retryablecall.error(streaming.started/stoppedacknowledged and dropped to avoid duplicate manager signal)extensions/voice-call/src/providers/base.ts— optionalsetPublicUrl?(url)on the provider interface;streamUrl?andstreamAuthToken?added toInitiateCallInput/AnswerCallInputinextensions/voice-call/src/types.tsextensions/voice-call/src/manager/context.ts+extensions/voice-call/src/manager.ts—streamSessionIssuerhook on the manager contextextensions/voice-call/src/manager/outbound.ts+extensions/voice-call/src/manager/events.ts— call the issuer before delegating to the provider when realtime is enabled and the provider is Telnyxextensions/voice-call/src/runtime.ts— wiresprovider.setPublicUrl?.(publicUrl)generically (was Twilio-cast), and connectsmanager.streamSessionIssuertorealtimeHandler.issueStreamSessionextensions/voice-call/src/config.ts— realtime gate at lines 871-874 widened to accepttelnyxalongsidetwilioReal behavior proof
Behavior or issue addressed: Realtime voice mode (
realtime.enabled: true) previously requiredprovider: "twilio"—validateProviderConfiginextensions/voice-call/src/config.ts:871-874explicitly rejected any non-Twilio provider, and the Telnyx provider had zero Media-Streams code (nostream_urlplumbing on dial/answer, no inbound WebSocket frame parser, no outbound frame serializer). Users on Telnyx had no path to sub-second voice latency over a carrier call. This PR wires Telnyx Media Streaming (PCMU 8 kHz μ-law) into the existingRealtimeCallHandlerbridge that Twilio already uses by adding a per-providerStreamFrameAdapter(Twilio retainsstreamSid, Telnyx omits it), generalizing the audio pacer to accept any serializer, embedding the streaming params on the Telnyx dial / answer-action payload per Telnyx's documented "AI agent" canonical pattern, and widening the realtime config gate to accepttelnyx.Real environment tested: OpenClaw gateway
2026.5.10-beta.1running locally on macOS (Darwin 24.6.0) viapnpm gateway:watch(Node 24.14.1, pnpm via Corepack). Patched voice-call plugin withprovider: "telnyx",realtime.enabled: true,realtime.provider: "openai",realtime.providers.openai: {}(OpenAI Realtime audio format pinned toaudio/pcmuon both legs perextensions/openai/realtime-voice-provider.ts:198),realtime.toolPolicy: "owner",realtime.consultPolicy: "substantive",realtime.consultFastMode: true,realtime.consultThinkingLevel: "low",outbound.defaultMode: "conversation",serve.port: 3421,serve.path: "/webhook". Bidirectional WebSocket tunneled through a reserved ngrok domain (noc-telnyx-eu.ngrok.io) wired bytunnel.provider: "ngrok". Telnyx Voice API Application webhook URL set tohttps://<ngrok-host>/webhook. Outbound call placed from a real Telnyx-managed number on my account to a real PSTN mobile destination I own (numbers redacted). Secrets sourced from~/.secrets.zshvia~/.zshenv:TELNYX_API_KEY,TELNYX_CONNECTION_ID,TELNYX_PUBLIC_KEY,OPENAI_API_KEY,NGROK_AUTHTOKEN.Exact steps or command run after this patch:
tmux kill-session -t openclaw-gateway-watch-main && OPENCLAW_TRACE_SYNC_IO=0 pnpm gateway:watch.tmux capture-pane -t openclaw-gateway-watch-main:0.0 -p -S -100 | grep -E "Realtime voice provider|ngrok tunnel active|Runtime initialized"should show the ngrok tunnel active line,[plugins] [voice-call] Realtime voice provider: openai, and[plugins] [voice-call] Runtime initialized.pnpm openclaw voicecall call --message "Hi, can you hear me?".tmux capture-pane -t openclaw-gateway-watch-main:0.0 -p -S -3000 | grep -E "voice-call|realtime|streaming".http://127.0.0.1:4040for the matchingcall.initiated→call.answered→streaming.started→call.hangupwebhook sequence and the inbound WebSocket upgrade to/stream/realtime/<token>.pnpm test:extension voice-call(45 files / 396 tests),pnpm test:contracts(62 files / 834 tests),pnpm tsgo:extensions,pnpm tsgo:extensions:test, all three import-boundary scripts underscripts/,pnpm exec oxfmt --checkon touched files,pnpm exec node scripts/run-oxlint.mjs extensions/voice-call/src,pnpm build— all green locally.Evidence after fix: Redacted runtime logs captured live from
pnpm gateway:watchstdout during a real outbound Telnyx call (callId2eabfaa1-3416-4677-96c7-71a131ba0e15, providerCallIdv3:kPoU84kZj-..., destination redacted). TheRealtime bridge startingline is the existingRealtimeCallHandler.handleCallaccepting Telnyx'sstart.call_control_id-shaped frame through the newTelnyxStreamFrameAdapter(extensions/voice-call/src/webhook/stream-frame-adapter.ts). Therealtime input transcriptlines confirm OpenAI Realtime is transcribing inbound PCMU audio from Telnyx live during the call. Therealtime outbound audio cleared by barge-inlines confirm OpenAI Realtime produced outbound audio AND the speech-start detector fired on caller barge-in, clearing the outbound queue (which writes{event:"clear"}to Telnyx via the new adapter).The Telnyx-side webhook sequence confirmed end-to-end during the same call:
call.initiated→call.answered→streaming.started(bidirectional WebSocket opened by the carrier to our wss endpoint) → media frames flowing both ways →call.hangupon call end. A separate live test withrealtime.toolPolicy: "owner"plus a substantive caller question exercised the agent-consult round trip —openclaw_agent_consultfired andrunEmbeddedPiAgentran on the carrier leg.Observed result after fix: Outbound Telnyx phone calls connect to the realtime bridge over the bidirectional PCMU WebSocket. Destination hears OpenAI Realtime's spoken greeting within ~1 second of pickup. Conversation runs full-duplex with sub-second voice-turn latency over the carrier leg. Barge-in works: when the caller speaks over the bot, the speech-start detector clears the outbound audio queue, writing
{event:"clear"}to Telnyx via the new adapter. Final transcripts land via the existingcall.speechevent path and flow through toopenclaw_agent_consultwhen the model decides to consult. Twilio realtime path was not regressed during this session — the only Twilio-side surface changed is the audio-pacer rename plus the frame-adapter extraction; both have round-trip tests asserting the Twilio envelope shape is byte-identical to pre-PR.What was not tested: Inbound Telnyx realtime call to a Telnyx-owned number —
inboundPolicy: "allowlist"config plus the answer-action streaming-field embedding are wired and unit-tested, but a real PSTN inbound call into the test number was not exercised this session; L16 / wideband codec (PCMU only here; the team-telnyx/realtime-ai-demo reference shows L16 needs libsamplerate-js resampling 16↔24 kHz which is intentionally out of scope, and the adapter is shaped so L16 is a drop-in follow-up); Plivo or Mock realtime (Plivo provider does not implement Media Streaming and Mock has no streaming at all; the config gate continues to reject realtime for any provider that is nottwilioortelnyx, so misconfiguration fails fast); high-concurrency / load (single-call manual smoke only; stale-call reaper andmaxConcurrentCallsunchanged from upstream and not stress-tested); Telnyx{event:"error"}WS frame real-world trigger (handled in the newTelnyxStreamFrameAdapterand unit-tested with a synthesized payload, but a real failure such as a revoked auth token or mid-stream network drop was not reproduced live this session).Automated checks
New / extended tests cover:
extensions/voice-call/src/webhook/stream-frame-adapter.test.ts(new) — Twilio + Telnyx frame round-trips (parse + serialize), ignored frames, malformed JSONextensions/voice-call/src/webhook/realtime-audio-pacer.test.ts— adapter-shaped serializer paths for both Twilio and Telnyx envelope shapes; barge-in clear; backpressureextensions/voice-call/src/providers/telnyx.test.ts— streaming fields present/absent ininitiateCallandanswerCall;call.streaming.failed→call.error;call.streaming.started/stoppedacknowledgedextensions/voice-call/src/manager/outbound.test.ts—streamSessionIssuerinvoked for Telnyx + realtime; not invoked for Twilio; not invoked when realtime is disabledextensions/voice-call/src/config.test.ts— realtime + telnyx accepted; realtime + other providers rejected with the updated error messageTest plan (reviewer checklist)
pnpm test:extension voice-callgreen locallypnpm tsgo:extensions+pnpm tsgo:extensions:testgreenstream-frame-adapter.tsTwilio adapter produces wire-identical output to the previous Twilio path (key assertion: outboundmedia/clear/markframes still includestreamSid)Known limitations / out of scope
streaming.enabledtranscription path is not wired here. That's a separate carrier-shape job that reuses the same adapter abstraction; tracked as follow-up.stream_track: "inbound_track"+ bidirectional viastream_bidirectional_mode: "rtp"is the only flow exercised. Other Telnyx track modes (outbound_track,both_tracks) aren't tested in this PR.stream_bidirectional_target_legs: "self"is hardcoded. Per Telnyx-side notes from the reference demo,"opposite"silently drops audio —"self"is the safe canonical choice for a single carrier leg. Re-evaluate when bridge / conference flows arrive.Backwards compatibility
RealtimeCallHandler.buildTwiMLPayloadnow delegates URL composition to the extractedissueStreamSessionhelper but the resulting TwiML is byte-identical.RealtimeTwilioAudioPacerclass is renamed toRealtimeAudioPacer(generalized). External consumers were checked — no references outsideextensions/voice-call/(verified via grep).VoiceCallProvider.setPublicUrl?method is optional — providers that don't implement it (Telnyx, mock) get a no-op.Resources
stream_id,start.call_control_id,{event:"error"}WS frames.stream_url+stream_codec+stream_bidirectional_*fields this PR embeds at dial time.actions/streaming_startreference for the late-attach case; relevant context for feat(voice-call): bidirectional Telnyx Media Streams for realtime voice #79575.stream_bidirectional_mode: "rtp"plus thestream_bidirectional_target_legssemantics used here.<Stream>TwiML — Twilio side of the same generic bridge for reference.AI-assisted disclosure
This PR was drafted with assistance from Claude (Anthropic). I reviewed every code change line-by-line, ran tests locally, and performed the live verification described above. The exact session prompts and design decisions are available on request.