Skip to content

feat(voice call): add Telnyx Media Streaming for voice-call realtime#81024

Merged
obviyus merged 5 commits into
openclaw:mainfrom
dynamite-bud:feat/ext/voice-call/telnyx-ws-streaming
May 12, 2026
Merged

feat(voice call): add Telnyx Media Streaming for voice-call realtime#81024
obviyus merged 5 commits into
openclaw:mainfrom
dynamite-bud:feat/ext/voice-call/telnyx-ws-streaming

Conversation

@dynamite-bud

@dynamite-bud dynamite-bud commented May 12, 2026

Copy link
Copy Markdown

Summary

Adds Telnyx Media Streaming (PCMU 8 kHz μ-law) support to @openclaw/voice-call so realtime voice providers (OpenAI Realtime, etc.) can drive Telnyx calls the same way they already drive Twilio calls. Until now, realtime.enabled was rejected at config time for any non-Twilio provider, and the Telnyx provider had zero streaming code — extensions/voice-call/src/config.ts gated 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.ts is provider-agnostic in shape (it consumes a RealtimeVoiceProviderPlugin from openclaw/plugin-sdk/realtime-voice for 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 configuring provider: "telnyx" with realtime.enabled: true hit a config-time error and never got past the gate.

Closes the gap by:

  1. Extracting WebSocket frame parsing/serialization into a small StreamFrameAdapter interface with one implementation per carrier (Twilio retains streamSid; Telnyx omits it).
  2. Generalizing the audio pacer to accept any serializer.
  3. Wiring Telnyx Media Streaming kickoff into the provider's dial and answer-action payloads per Telnyx's documented canonical pattern (no actions/streaming_start call needed).
  4. Threading a stream session issuer from the realtime handler through the manager so carriers that attach streaming at dial/answer time can embed the URL + token in their carrier API payloads.
  5. Widening the realtime config gate from provider === "twilio" to allow telnyx as 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)

  • Codec: PCMU 8 kHz μ-law only. 20 ms / 160-byte frames, base64 passthrough between Telnyx and OpenAI Realtime (which accepts audio/pcmu natively per extensions/openai/realtime-voice-provider.ts:198). No resampling on the hot path.
  • Kickoff: dial-time inline for outbound (POST /v2/calls payload includes stream_url, stream_codec: "PCMU", stream_bidirectional_* fields, stream_auth_token), and answer-action inline for inbound (POST /calls/{id}/actions/answer includes the same fields). Mirrors Telnyx's documented "AI agent" pattern and avoids the "one streaming operation per call" constraint that an explicit actions/streaming_start would impose.
  • Frame layer: per-provider StreamFrameAdapter in extensions/voice-call/src/webhook/stream-frame-adapter.ts. Realtime handler stays single-implementation; new providers can drop in by writing one adapter.
  • Provider boundary: the Telnyx provider only embeds streaming params into carrier API payloads — it never composes the stream URL itself. The realtime handler owns URL composition via the new issueStreamSession method; manager calls it via a thin streamSessionIssuer hook (Twilio path is unchanged since Twilio learns the URL from TwiML).
  • Scope: outbound + inbound realtime. Streaming-transcription path (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) — StreamFrame union + StreamFrameAdapter interface + Twilio/Telnyx implementations
  • extensions/voice-call/src/webhook/realtime-handler.tsissueStreamSession extracted, WS upgrade dispatches via adapter
  • extensions/voice-call/src/webhook/realtime-audio-pacer.ts — generalized to accept a serializer (was Twilio-shaped)
  • extensions/voice-call/src/providers/telnyx.tsinitiateCall + answerCall embed buildTelnyxStreamingFields(streamUrl, token) when streaming is requested; new webhook events call.streaming.failed → non-retryable call.error (streaming.started/stopped acknowledged and dropped to avoid duplicate manager signal)
  • extensions/voice-call/src/providers/base.ts — optional setPublicUrl?(url) on the provider interface; streamUrl? and streamAuthToken? added to InitiateCallInput / AnswerCallInput in extensions/voice-call/src/types.ts
  • extensions/voice-call/src/manager/context.ts + extensions/voice-call/src/manager.tsstreamSessionIssuer hook on the manager context
  • extensions/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 Telnyx
  • extensions/voice-call/src/runtime.ts — wires provider.setPublicUrl?.(publicUrl) generically (was Twilio-cast), and connects manager.streamSessionIssuer to realtimeHandler.issueStreamSession
  • extensions/voice-call/src/config.ts — realtime gate at lines 871-874 widened to accept telnyx alongside twilio

Real behavior proof

Behavior or issue addressed: Realtime voice mode (realtime.enabled: true) previously required provider: "twilio"validateProviderConfig in extensions/voice-call/src/config.ts:871-874 explicitly rejected any non-Twilio provider, and the Telnyx provider had zero Media-Streams code (no stream_url plumbing 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 existing RealtimeCallHandler bridge that Twilio already uses by adding a per-provider StreamFrameAdapter (Twilio retains streamSid, 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 accept telnyx.

Real environment tested: OpenClaw gateway 2026.5.10-beta.1 running locally on macOS (Darwin 24.6.0) via pnpm gateway:watch (Node 24.14.1, pnpm via Corepack). Patched voice-call plugin with provider: "telnyx", realtime.enabled: true, realtime.provider: "openai", realtime.providers.openai: {} (OpenAI Realtime audio format pinned to audio/pcmu on both legs per extensions/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 by tunnel.provider: "ngrok". Telnyx Voice API Application webhook URL set to https://<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.zsh via ~/.zshenv: TELNYX_API_KEY, TELNYX_CONNECTION_ID, TELNYX_PUBLIC_KEY, OPENAI_API_KEY, NGROK_AUTHTOKEN.

Exact steps or command run after this patch:

  1. Restart the gateway watch session so the patched plugin loads cleanly: tmux kill-session -t openclaw-gateway-watch-main && OPENCLAW_TRACE_SYNC_IO=0 pnpm gateway:watch.
  2. Confirm realtime is wired: 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.
  3. Place a live outbound call to the test destination: pnpm openclaw voicecall call --message "Hi, can you hear me?".
  4. Pick up the destination phone and speak — the bot greets, listens, replies; speaking over the bot exercises barge-in.
  5. Tail the gateway during and after the call: tmux capture-pane -t openclaw-gateway-watch-main:0.0 -p -S -3000 | grep -E "voice-call|realtime|streaming".
  6. Cross-reference Telnyx Mission Control Portal call detail and the ngrok inspector at http://127.0.0.1:4040 for the matching call.initiatedcall.answeredstreaming.startedcall.hangup webhook sequence and the inbound WebSocket upgrade to /stream/realtime/<token>.
  7. Confirm pre-PR gates: 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 under scripts/, pnpm exec oxfmt --check on 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:watch stdout during a real outbound Telnyx call (callId 2eabfaa1-3416-4677-96c7-71a131ba0e15, providerCallId v3:kPoU84kZj-..., destination redacted). The Realtime bridge starting line is the existing RealtimeCallHandler.handleCall accepting Telnyx's start.call_control_id-shaped frame through the new TelnyxStreamFrameAdapter (extensions/voice-call/src/webhook/stream-frame-adapter.ts). The realtime input transcript lines confirm OpenAI Realtime is transcribing inbound PCMU audio from Telnyx live during the call. The realtime outbound audio cleared by barge-in lines 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).

21:34:25 [voice-call] Outbound call initiated: callId=2eabfaa1-3416-4677-96c7-71a131ba0e15 providerCallId=v3:kPoU84kZj-... mode=conversation preConnectDtmf=no initialMessage=yes
21:34:25 [ws] res voicecall.initiate 1077ms
21:34:48 [voice-call] Starting max duration timer (300s) for call 2eabfaa1-3416-4677-96c7-71a131ba0e15
21:34:48 [voice-call] Realtime bridge starting for call 2eabfaa1-3416-4677-96c7-71a131ba0e15 (providerCallId=v3:kPoU84kZj-..., initialGreeting=queued)
21:34:58 [voice-call] realtime input transcript callId=2eabfaa1-... providerCallId=v3:kPoU84kZj-... final=false chars=14 aggregateChars=14
21:35:04 [voice-call] realtime outbound audio cleared by barge-in callId=2eabfaa1-... providerCallId=v3:kPoU84kZj-... queuedBytes=1280
21:35:04 [voice-call] realtime outbound audio clear requested callId=2eabfaa1-... providerCallId=v3:kPoU84kZj-... queuedBytes=0
21:35:06 [voice-call] realtime outbound audio cleared by barge-in callId=2eabfaa1-... providerCallId=v3:kPoU84kZj-... queuedBytes=2400
21:35:07 [voice-call] realtime input transcript callId=2eabfaa1-... providerCallId=v3:kPoU84kZj-... final=true chars=39 aggregateChars=39

The Telnyx-side webhook sequence confirmed end-to-end during the same call: call.initiatedcall.answeredstreaming.started (bidirectional WebSocket opened by the carrier to our wss endpoint) → media frames flowing both ways → call.hangup on call end. A separate live test with realtime.toolPolicy: "owner" plus a substantive caller question exercised the agent-consult round trip — openclaw_agent_consult fired and runEmbeddedPiAgent ran 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 existing call.speech event path and flow through to openclaw_agent_consult when 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 not twilio or telnyx, so misconfiguration fails fast); high-concurrency / load (single-call manual smoke only; stale-call reaper and maxConcurrentCalls unchanged from upstream and not stress-tested); Telnyx {event:"error"} WS frame real-world trigger (handled in the new TelnyxStreamFrameAdapter and 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

CI=true pnpm test:extension voice-call           # 45 files / 394 tests pass
CI=true pnpm tsgo:extensions                     # exit 0
CI=true pnpm tsgo:extensions:test                # exit 0

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 JSON
  • extensions/voice-call/src/webhook/realtime-audio-pacer.test.ts — adapter-shaped serializer paths for both Twilio and Telnyx envelope shapes; barge-in clear; backpressure
  • extensions/voice-call/src/providers/telnyx.test.ts — streaming fields present/absent in initiateCall and answerCall; call.streaming.failedcall.error; call.streaming.started/stopped acknowledged
  • extensions/voice-call/src/manager/outbound.test.tsstreamSessionIssuer invoked for Telnyx + realtime; not invoked for Twilio; not invoked when realtime is disabled
  • extensions/voice-call/src/config.test.ts — realtime + telnyx accepted; realtime + other providers rejected with the updated error message

Test plan (reviewer checklist)

  • pnpm test:extension voice-call green locally
  • pnpm tsgo:extensions + pnpm tsgo:extensions:test green
  • Confirm stream-frame-adapter.ts Twilio adapter produces wire-identical output to the previous Twilio path (key assertion: outbound media/clear/mark frames still include streamSid)
  • Smoke an outbound Telnyx realtime call against a real number (the path proven in this PR)
  • Optional: smoke an inbound Telnyx realtime call by dialing INTO a Telnyx number associated with the configured Voice API Application

Known limitations / out of scope

  • PCMU only. L16 / 16 kHz wideband and the libsamplerate-js resampler from the team-telnyx/realtime-ai-demo are deliberately deferred. The adapter design leaves L16 as a drop-in.
  • Telnyx + streaming.enabled transcription 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 via stream_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

  • Twilio realtime path is unchanged. Twilio learns its stream URL from TwiML exactly as before; RealtimeCallHandler.buildTwiMLPayload now delegates URL composition to the extracted issueStreamSession helper but the resulting TwiML is byte-identical.
  • The RealtimeTwilioAudioPacer class is renamed to RealtimeAudioPacer (generalized). External consumers were checked — no references outside extensions/voice-call/ (verified via grep).
  • The VoiceCallProvider.setPublicUrl? method is optional — providers that don't implement it (Telnyx, mock) get a no-op.

Resources

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.

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

clawsweeper Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge.

Summary
The PR adds Telnyx realtime Media Streaming support to the voice-call plugin through per-carrier stream frame adapters, stream-session URL/token plumbing, Telnyx dial/answer streaming fields, config validation, and tests.

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
Sufficient (logs): The PR body includes redacted live Telnyx call logs showing after-fix bridge startup, transcript flow, outbound audio/barge-in behavior, and observed end-to-end results.

Next step before merge
Manual review is needed because the remaining decision is product/owner choice between two active Telnyx realtime lifecycle designs, not a narrow repair ClawSweeper can make safely.

Security
Cleared: The diff is scoped to voice-call source and tests, adds no dependency/workflow/package execution changes, and does not add logging of the Telnyx stream auth token.

Review details

Best 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:

  • steipete: Peter Steinberger dominates local history for the voice-call config, realtime handler, and Telnyx provider paths, and the PR discussion explicitly defers Telnyx realtime direction to steipete. (role: recent area contributor and likely feature-history owner; confidence: high; commits: 42c17adb5e4d, 29b587e73cbd, 1d8968c8a821; files: extensions/voice-call/src/config.ts, extensions/voice-call/src/webhook/realtime-handler.ts, extensions/voice-call/src/providers/telnyx.ts)
  • Shakker: Current checkout blame for the exact config gate, realtime parser, and Telnyx provider lines points to Shakker through a recent broad import/current-main commit, though that is weaker than feature ownership. (role: current-main provenance signal; confidence: low; commits: eea3e2c0f792, e14b54596fa2; files: extensions/voice-call/src/config.ts, extensions/voice-call/src/webhook/realtime-handler.ts, extensions/voice-call/src/providers/telnyx.ts)

Remaining risk / open question:

  • Two active PRs solve the same Telnyx realtime feature with different lifecycle models, so maintainers need to choose between inline dial/answer streaming and the actions/streaming_start approach before merging either path.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 094f6d83c727.

@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 12, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 12, 2026
@dynamite-bud dynamite-bud changed the title Feat(voice call): Telnyx Media streaming feat(voice call): Telnyx Media streaming May 12, 2026
@dynamite-bud dynamite-bud changed the title feat(voice call): Telnyx Media streaming feat(voice call): add Telnyx Media Streaming for voice-call realtime May 12, 2026
@dmmc12

dmmc12 commented May 12, 2026

Copy link
Copy Markdown

Yes, I tested this and it works locally. 👍

@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 12, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 12, 2026
@dynamite-bud

dynamite-bud commented May 12, 2026

Copy link
Copy Markdown
Author

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.

@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 12, 2026
@obviyus obviyus self-assigned this May 12, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 12, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 12, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 12, 2026
dynamite-bud and others added 5 commits May 12, 2026 21:17
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]>
@obviyus
obviyus force-pushed the feat/ext/voice-call/telnyx-ws-streaming branch from 5a52fdd to 0d75611 Compare May 12, 2026 15:50
@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 12, 2026
@obviyus
obviyus merged commit 4272ce1 into openclaw:main May 12, 2026
82 of 84 checks passed
@obviyus

obviyus commented May 12, 2026

Copy link
Copy Markdown
Contributor

Landed via rebase onto main.

  • Scoped tests: CI=true pnpm test extensions/voice-call/src/webhook/realtime-handler.test.ts extensions/voice-call/src/webhook/stream-frame-adapter.test.ts extensions/voice-call/src/providers/telnyx.test.ts extensions/voice-call/src/manager/outbound.test.ts; CI=true pnpm test:extension voice-call; CI=true pnpm tsgo:extensions; CI=true pnpm tsgo:extensions:test; pnpm exec oxfmt --check <changed voice-call files>; CI=true pnpm exec node scripts/run-oxlint.mjs extensions/voice-call/src
  • Changelog: CHANGELOG.md updated
  • CI note: security-dependency-audit/security-fast failed on the exact head due existing protobufjs <=7.5.5 advisories; reproduced on origin/main with no dependency/workflow/audit-script changes in this PR.
  • Land commit: 0d75611
  • Merge commit: 4272ce1

Thanks @dynamite-bud!

Re-review progress:

@obviyus

obviyus commented May 12, 2026

Copy link
Copy Markdown
Contributor

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.

@dynamite-bud

Copy link
Copy Markdown
Author

Thank you very much @obviyus

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: supplied External PR includes structured after-fix real behavior proof. size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants