Skip to content

feat(voice-call): bidirectional Telnyx Media Streams for realtime voice#79575

Closed
happydog-bot wants to merge 1 commit into
openclaw:mainfrom
happydog-bot:feat/telnyx-realtime-media-streams
Closed

feat(voice-call): bidirectional Telnyx Media Streams for realtime voice#79575
happydog-bot wants to merge 1 commit into
openclaw:mainfrom
happydog-bot:feat/telnyx-realtime-media-streams

Conversation

@happydog-bot

@happydog-bot happydog-bot commented May 9, 2026

Copy link
Copy Markdown

Summary

Adds bidirectional Telnyx Media Streaming support to the realtime voice path so users can run sub-second-latency voice on Telnyx + a realtime LLM (Gemini Live, OpenAI Realtime, etc.) end-to-end. Previously realtime.enabled: true required provider: "twilio"; this lifts that restriction by adding the small amount of provider-specific transport plumbing Telnyx needs (it negotiates the stream via the streaming_start Call Control action rather than via TwiML).

The same RealtimeCallHandler WebSocket bridge serves both providers. Telnyx and Twilio Media Streams use a near-identical wire format (event: "media" with base64 audio, clear, mark, stop); the only differences are inbound field names (stream_id/call_control_id vs streamSid/callSid) and the fact that Telnyx emits a connected frame before start. Both shapes are now accepted.

Layered changes

  • Validation (extensions/voice-call/src/config.ts): provider !== "twilio" becomes provider !== "twilio" && provider !== "telnyx" when realtime.enabled is true.
  • Provider contract (extensions/voice-call/src/providers/base.ts + types.ts): adds optional startMediaStream / stopMediaStream methods + StartMediaStreamInput / StopMediaStreamInput shapes. Twilio doesn't implement them; its TwiML path is unchanged.
  • Telnyx provider (extensions/voice-call/src/providers/telnyx.ts): implements startMediaStream (issues streaming_start with stream_bidirectional_mode: "rtp" + stream_bidirectional_codec: "PCMU" defaults) and stopMediaStream (idempotent streaming_stop via allowNotFound).
  • Manager hook (extensions/voice-call/src/manager.ts): adds setRealtimeStreamStarter which fires on call.answered for non-TwiML realtime providers.
  • Realtime handler (extensions/voice-call/src/webhook/realtime-handler.ts): WS handler now ignores Telnyx's connected frame and accepts either Twilio (start.streamSid / start.callSid) or Telnyx (top-level stream_id / start.call_control_id) start-event shapes. Adds mintStreamUrl() so non-TwiML providers can construct the wss URL.
  • Runtime wiring (extensions/voice-call/src/runtime.ts): wires the manager's stream-starter callback for any provider with startMediaStream. Includes a per-call dedup set so that providers (notably Telnyx) which fire call.answered twice — once for their auto-answer and once for our explicit answer POST — only issue one streaming_start. The dedup is reset on failure so retries still work.

Codec choice

Default is PCMU 8 kHz (mu-law) to match the existing audio pacer (extensions/voice-call/src/webhook/realtime-audio-pacer.ts), which encodes the Live model's PCM output to 8 kHz mu-law for Twilio Media Streams. Telnyx accepts the same shape, so realtime providers work over Telnyx with no resample changes. StartMediaStreamInput exposes codec + samplingRate overrides for callers that prefer L16 16 kHz (recommended by Telnyx for AI integrations) once a higher-rate audio pacer lands.

Verification

  • pnpm test extensions/voice-call extensions/google — 21 test files / 206 tests pass, including 6 new tests:
    • config.test.ts — accepts telnyx + realtime; still rejects plivo + realtime
    • providers/telnyx.test.tsstartMediaStream defaults; track/codec/sampling-rate overrides; stopMediaStream idempotent cleanup
    • webhook/realtime-handler.test.tsmintStreamUrl returns null without public origin and a fresh-token wss URL with one; WS handler accepts Telnyx connected + start { stream_id, call_control_id } shape
  • pnpm tsgo --project tsconfig.extensions.projects.json — clean
  • pnpm exec oxfmt --check on all 10 changed files — clean

Real behavior proof

Behavior or issue addressed: Realtime voice mode (realtime.enabled: true) previously required provider: "twilio"; users on Telnyx had no path to sub-second voice latency. This PR wires Telnyx's streaming_start Call Control action and the WebSocket dialect into the same RealtimeCallHandler bridge that Twilio uses, so realtime voice now works on Telnyx end-to-end. Also fixes a call.answered double-fire from Telnyx that, without dedup, would issue two streaming_start commands per call.

Real environment tested: OpenClaw gateway 2026.5.7 running locally on Linux 6.17 (Ryzen AI Max+ 395), patched voice-call plugin in realtime mode, provider: "telnyx", realtime.provider: "google" → Gemini Live model gemini-2.5-flash-native-audio-preview-12-2025, real Telnyx number +1 (651) 422-0083 with bidirectional media streaming over an ngrok WebSocket tunnel (stretch-unclaimed-foe.ngrok-free.dev). Inbound calls placed from a real US cell phone over PSTN.

Exact steps or command run after this patch:

  1. Apply the 10-file patch and restart the gateway: openclaw gateway restart
  2. Confirm realtime is wired: journalctl --user -u openclaw-gateway -n 50 | grep "Realtime voice provider" should show Realtime voice provider: google
  3. Place a live inbound call from a real cell phone to the Telnyx number
  4. Ask a tool-needing question (consult tool) on the call to exercise the full realtime + agent path
  5. Tail the gateway during and after the call: journalctl --user -u openclaw-gateway -f | grep -E "voice-call|telnyx call|realtime"
  6. Confirm the per-call dedup gate: only one Starting realtime media stream line per call, even though Telnyx fires call.answered twice

Evidence after fix: Redacted runtime logs from journalctl --user -u openclaw-gateway during a real live call. The Starting realtime media stream for telnyx call ... -> wss://... line is the new code path (extensions/voice-call/src/runtime.ts stream-starter wiring → extensions/voice-call/src/providers/telnyx.ts:startMediaStream); Realtime bridge starting is the existing RealtimeCallHandler.handleCall accepting Telnyx's stream_id + start.call_control_id shape that this PR added support for; realtime tool call/realtime tool result lines are real openclaw_agent_consult round-trips through the openclaw agent.

[voice-call] Inbound call accepted: policy is open
[voice-call] Created inbound call record: ac17e818-6054-4237-8565-387deabed891 from +16127153507
[voice-call] Starting max duration timer (300s) for call ac17e818-6054-4237-8565-387deabed891
[voice-call] Starting realtime media stream for telnyx call ac17e818-6054-4237-8565-387deabed891 -> wss://stretch-unclaimed-foe.ngrok-free.dev/voice/stream/realtime/00ff8eb3-c37d-4686-b390-1f78c980e39e
[voice-call] Realtime call ac17e818-6054-4237-8565-387deabed891 initial greeting queued
[voice-call] Realtime bridge starting for call ac17e818-6054-4237-8565-387deabed891 (providerCallId=v3:4fgmlWVhG5JZ1X44GfI9WC-cKZGkJP-IQjPOgHPZWmr0D3X7WNlCdw, initialGreeting=queued)
[voice-call] realtime tool call: name=openclaw_agent_consult callId=ac17e818... args={"question":"How many prospects are currently in the system?"}
[voice-call] realtime tool result: name=openclaw_agent_consult callId=ac17e818... ms=4493 result={"text":"I currently have 67 prospects in the system, though one is a test entry and should be ignored."}

Observed result after fix: Inbound Telnyx phone calls connect to the realtime bridge over the bidirectional WebSocket, the caller hears the model's greeting within ~1 second of pickup, and the conversation runs full-duplex with sub-second voice turn latency. Tool calls round-trip through the full openclaw agent (openclaw_agent_consult) in 3.5–9 seconds with the existing working-on-it filler bridging the gap. Per-call dedup observed: exactly one Starting realtime media stream line per real call even with Telnyx's two call.answered events. Zero regressions observed on the Twilio realtime path during the same session.

What was not tested: Outbound calls from openclaw to a real PSTN destination via Telnyx (only inbound was exercised; outbound shares the same code path but the dial is initiated by provider.initiateCall which is unchanged); the L16/16000 codec/samplingRate overrides on StartMediaStreamInput (only the PCMU/8 kHz default was exercised because the existing realtime audio pacer is locked to mu-law/8 kHz today); Plivo realtime (Plivo provider does not implement startMediaStream so the new wiring no-ops there, but no Plivo number was on hand to confirm).

Sources

@openclaw-barnacle openclaw-barnacle Bot added channel: voice-call Channel integration: voice-call size: M triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 9, 2026
@clawsweeper

clawsweeper Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Close: the central Telnyx realtime voice feature is now implemented on current main by the merged canonical PR, and this branch’s distinct post-answer streaming_start approach is superseded by the inline dial/answer implementation that landed.

So I’m closing this here and keeping the remaining discussion on the canonical linked item.

Review details

Best possible solution:

Keep the merged inline Telnyx realtime implementation from #81024 as the canonical path, and handle late-attach streaming_start support later only if a separate mid-call transfer use case is requested.

Do we have a high-confidence way to reproduce the issue?

Not applicable: this is a feature PR, not a bug report. The PR body supplied live Telnyx logs, and current main now contains source and test coverage for the same user-facing Telnyx realtime capability through the merged canonical design.

Is this the best way to solve the issue?

No for this branch as a merge target. The owner-boundary feature landed through #81024 using inline Telnyx dial/answer streaming, while this branch’s late-attach design is now superseded and still has a token-logging defect.

Security review:

Security review needs attention: The branch is superseded, but its diff would expose a short-lived realtime stream token in gateway logs if merged as-is.

  • [medium] Full realtime stream token is logged — extensions/voice-call/src/runtime.ts:416
    The proposed runtime log includes the complete streamUrl, including the one-time token consumed by handleWebSocketUpgrade, which is a live media-path credential during the call.
    Confidence: 0.88

What I checked:

Likely related people:

  • dynamite-bud: Authored the merged Telnyx realtime Media Streaming implementation and carrier-doc alignment commits that now cover the same central feature on main. (role: canonical feature implementer; confidence: high; commits: bb1b075118e5, 148e5467b546; files: extensions/voice-call/src/providers/telnyx.ts, extensions/voice-call/src/webhook/stream-frame-adapter.ts, extensions/voice-call/src/webhook/realtime-handler.ts)
  • obviyus: Merged the superseding PR and authored the follow-up commit binding Telnyx realtime streams to issued calls. (role: merger and recent follow-up owner; confidence: high; commits: 665c29d4dc7c, 4272ce15978d; files: extensions/voice-call/src/webhook/realtime-handler.ts, extensions/voice-call/src/providers/telnyx.ts, CHANGELOG.md)
  • steipete: Recent history touches the voice-call plugin paths, and the PR discussion explicitly routed final Telnyx realtime direction to this voice-call area owner signal. (role: recent adjacent area contributor; confidence: medium; commits: 2379da6cf1c8; files: extensions/voice-call/src/config.ts, extensions/voice-call/src/webhook/realtime-handler.ts, extensions/voice-call/src/providers/telnyx.ts)

Codex review notes: model gpt-5.5, reasoning high; reviewed against 1f1a5c205c82; fix evidence: commit 4272ce15978d, main fix timestamp 2026-05-12T21:23:50+05:30.

@openclaw-barnacle openclaw-barnacle Bot added proof: supplied External PR includes structured after-fix real behavior proof. and removed triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 9, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 9, 2026
@hdwebpros

Copy link
Copy Markdown

Tested by a human here, works local.

@dynamite-bud

dynamite-bud commented May 12, 2026

Copy link
Copy Markdown

Cross-linking from the parallel Telnyx Media Streaming PR: #81024.

The implementations took different paths on carrier-side kickoff: this PR uses actions/streaming_start (post-answer REST), #81024 inlines the streaming params on the dial / answer-action payload. Both are valid Call Control v2 surfaces. Want to surface the reasoning for the inline approach so maintainers have it side-by-side, not as advocacy for one vs the other:

  1. A realtime conversation starts from the get-go, not mid-call. The use case isn't "the call has been running for a while and now I want to attach streaming" — the bot is the answer-er. There's no human leg that needs to keep going while the bot establishes a separate channel. The bot leg IS the stream. Treating that as an upgrade adds a state ("dialing, but not yet streaming") that has no business meaning for this use case.
  2. It removes one API roundtrip per call. POST /v2/calls already carries webhook_url + client_state + every other dial-time field — adding stream_url + stream_codec + stream_bidirectional_* to the same request is one extra body, not one extra roundtrip. actions/streaming_start is a second authenticated REST call after the carrier has already accepted the dial, and you have to track when to fire it.
  3. It matches Telnyx's own published reference. team-telnyx/realtime-ai-demo embeds streaming params on dial (src/telnyx/api.ts). The Telnyx "real-time media streaming for AI agents" guide documents the same pattern as the canonical AI-agent flow. Both actions/streaming_start and inline are documented surfaces; for AI-agent calls, Telnyx points at inline.
  4. It avoids the "one streaming operation per call" trap. Telnyx imposes a single streaming attachment per call. Doing it at dial time means there's no risk of accidentally firing streaming_start twice (e.g. on call.answered duplicates — which Telnyx is known to deliver under retry conditions), and the carrier never has a window where it's connected but un-streamed.
  5. Symmetric for inbound. Same pattern carries over to actions/answer: attach streaming params on the answer command. No separate code path needed.

Concrete limitations the streaming_start path has to mitigate

To be fair to #79575 — none of these are bugs in that PR, the author has clearly addressed them. The point is that the inline approach in #81024 avoids them by design rather than mitigating them:

  • call.answered double-fire: Telnyx delivers call.answered twice under retry conditions; the streaming_start approach has to add explicit per-call dedup on the answer handler so the second call.answered doesn't fire a second streaming_start (which would fail with "one streaming operation per call"). feat(voice-call): bidirectional Telnyx Media Streams for realtime voice #79575's PR description explicitly calls this out as a fix it had to add. Inline dial-time params are bound when the carrier accepts POST /v2/calls; there's no "answered" handler involved in stream setup, so the dedup concern doesn't exist.
  • Race window between answer and stream attach: Between call.answered firing and our streaming_start action arriving back at Telnyx, the call is connected but no stream exists. Typically <100ms but can be longer under load — and audio the caller speaks in that window is lost. Inline params are part of the dial command, so the carrier brings the stream up at the moment of answer with no client-side roundtrip in between.
  • Action ordering / lifecycle plumbing: streaming_start must fire after answer but before the user expects to hear audio. The action-based path has to track per-call state ("dialed, answered, not yet streaming") and react on the right webhook. Inline pushes all of that into the dial payload; the carrier owns the lifecycle.
  • Two failure surfaces instead of one: With streaming_start, dial can succeed while streaming setup fails — leaving a connected call with no AI bridge attached. The error path has to either re-attempt streaming_start, hang up the call, or accept the leg as dead air. With inline, dial-and-stream succeed or fail together.

When actions/streaming_start is the right call: when the bot joins mid-call (warm transfer, supervised hand-off, mid-call escalation, or attaching a recording stream to an existing human-to-human leg). Those cases need the action-based approach as a second mechanism. If maintainers want both paths, happy to coordinate so the dial/answer-inline approach is the default and the action-based path handles the late-attach case — they're not actually competing if scoped to different lifecycle moments.

Either way, deferring to maintainers (likely @steipete given recent voice-call ownership) on direction.

@clawsweeper clawsweeper Bot closed this May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: voice-call Channel integration: voice-call proof: sufficient ClawSweeper judged the real behavior proof convincing. proof: supplied External PR includes structured after-fix real behavior proof. size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants