Optional_Live OTel context of the CURRENT voice.turn, published by
defaultVoiceCall for background-receive-loop adapters
(Pipecat/Twilio) to parent their detached-callback recv spans under the
turn (#774 — the reusable pattern Twilio PR5 inherits). undefined between
turns, so a callback firing outside a turn skips its span rather than
parenting under a closed turn. Internal (underscore) — not a public API.
ReadonlyaccountSET when the AGENT deliberately ended the call (e.g. an ElevenLabs hosted
agent invoking the end_call system tool), as opposed to the transport
dropping. A scripted turn arriving after this concludes the conversation
instead of failing the run — the agent behaved as designed. Assertions and
judges can read it to reason about WHO ended the call.
OptionalagentSet when the adapter has emitted its first agent audio chunk for the
current turn — gates timing-based barge-in. Concrete adapters expose
this so scenario.interrupt can wait for real speech before
firing the interruption. Optional: adapters without server-VAD-style
interrupt sequencing can leave it undefined.
Optional ReadonlyallowedOptional ReadonlyallowedReadonlyauthReadonlycapabilitiesDeclaration of what this adapter can and cannot do. Concrete subclasses MUST publish a non-default value; the base instance defaults to "nothing supported" so capability-gated steps fail safely when an adapter forgets to declare.
ReadonlyfetchReadonlyhttpOptionalnameThe name that LangWatch shows as the target of the run. When you do not set it, the framework uses the class name of the adapter.
Optional ReadonlyonReadonlyphoneOptionalpublicCalled by the server when an inbound webhook is rejected (caller filter or bad signature). Exposed for tests; production callers see the HTTP response and never look at this counter.
Hard cap on a single agent turn's audio. Prevents runaway loops if a transport never signals end-of-stream. 30s = a long sentence.
Tail silence: once the first agent chunk arrives, keep draining receiveAudio until no chunk shows up within this many seconds — that's how we detect the agent finished talking.
Seconds to wait for agent audio after sending user audio: the STT + LLM +
TTS budget for one agent turn. Kept identical to Python's
VoiceAgentAdapter.response_timeout so the same scenario passes or fails
the same way in both SDKs.
Raise it for an agent that runs a tool call or a retrieval step before it speaks:
const agent = elevenLabsAgent({ agentId, apiKey });
agent.responseTimeout = 180; // wait up to 3 minutes
OptionalstreamingIncremental transcript text emitted while the agent speaks. Populated
by adapters that advertise capabilities.streamingTranscripts. Read
by scenario.interrupt when afterWords: N is set.
Bounded grace-wait (seconds) for the agent turn's transcript AFTER audio
drains (#734). Audio silence closes the turn (responseTailSilence), but a
live voice agent (hosted ElevenLabs) delivers the turn's text on a SEPARATE
socket event (agent_response → lastAgentTranscript). When that event
lands after the audio-silence boundary, snapshotting lastAgentTranscript
at drain-close reads null and the turn reaches the text-only simulator as
a bare [audio message] — the simulator then fabricates.
The default call() flow (defaultVoiceCall) polls this field up to
this ceiling for a pending transcript before reading it. It short-circuits
the INSTANT lastAgentTranscript is already set (zero added latency on the
happy path — the common case where the transcript won the race) and only
elapses when the transcript genuinely never arrives, so a real ElevenLabs
drop still terminates the turn. Set to 0 to disable the wait.
Optional ReadonlytunnelReadonlyvalidateInternal
The call generation captured by the media loop at adoption (#762 P1). A later dial bumps it; a socket adopted under the old value is stale.
Internal
The call SID origination returned — what a start frame's
callSid must match in a-leg mode.
Internal
Test-only view of the disconnect-counter (#775 review): lets a test poll for a REAL wire delivery to have landed before proceeding, instead of a blind sleep.
Internal
Test-only view of whether the max-duration watchdog is armed (#762 P1/P2): a stale socket's teardown must not disarm it.
Internal
Internal
Test-only view of the terminal flag (#762 P1): lets a test assert
a stale socket's stop did NOT mark the live stream ended.
Internal
A-leg WS auth expectation the media loop enforces (#762): the
per-call nonce minted at origination, or undefined in every un-gated mode
(b-leg, originator-only, inbound). Presence ARMS enforcement.
Internal
Test-only view of the transport state the server nulls on teardown.
Internal
The socket that IS this adapter's live transport, or null.
runStreamSession compares against it so only the owning socket may null
the transport on teardown; tests read it as their view of that same state.
The Twilio call SID for the current/last call, once origination has
returned it. undefined before a call is placed or after disconnect.
Test seam: the running webhook server's bound HTTP base URL (e.g.
http://127.0.0.1:54321). Useful for tests that don't want a tunnel.
Throws if the adapter isn't connected.
Protected_Wait until the duration cap elapses.
Test seam (same role as _driveMediaStream): tests replace this on the
instance to drive expiry on controlled time instead of waiting for real.
Disarm the watchdog. Idempotent; safe when none was ever armed.
Test seam: directly drive a media-stream loop over a provided socket.
Production code reaches the loop via the /twilio/stream route.
Test seam: drive the FULL production per-connection wrapper
(TwilioWebhookServer.runStreamSession) over a provided socket — the
loop PLUS the finally that nulls _streamWs/_streamSid, exactly as the
real /twilio/stream handler does after a call ends. Unlike
_driveMediaStream (loop only), this reproduces the #695 teardown
race so a follow-up receiveAudio runs against nulled transport state.
Internal
Single inbound entry point for the media loop. With the speech gate on, only chunks the gate admits (speech, hangover, pre-roll, the terminal sentinel) reach the queue — that arrival gap during the callee's silence is what lets the drain's tail-silence path end the turn.
Internal
Internal
Internal
Disconnect-counter (#775 Tier 2b): one dtmf frame received.
Internal
Disconnect-counter (#775 Tier 2b): one media frame received.
Internal
Disconnect-counter (#775 Tier 2b): one /twilio/voice POST.
Internal
Re-arm per-CALL state at media-stream-loop entry. All three parts
are per-call, not per-connection, so a second session on the same connected
adapter must not inherit any of them. The ended-reason matters as much as
the flag: "max_duration" wins every tie by design, so left standing it
would report a later, cleanly-stopped session as having hit the cap.
The flag alone is not enough: the previous call's finally ENQUEUED a
terminal sentinel, and if that call ended while no drain was running (the
caller hung up between turns) the sentinel is still buffered. receiveAudio
drains a non-empty queue without checking liveness, so the new call's first
receiveAudio would hand that stale empty chunk to drainAgentResponse as
its first chunk — and the drain breaks on an empty chunk, truncating the new
call's first agent turn to silence.
No frame of this call has been enqueued yet, so buffered chunks are the
previous session's residue. clearBuffered (not reset) so a consumer
already parked in take() stays parked for the new call's real audio.
Invalidate any socket adopted under a prior call generation (#762 P1).
Bumping _callGeneration evicts a socket that connected — and even ADOPTED
— before this dial armed the nonce: the media loop captured the old
generation at adoption and closes itself on its next frame once the counter
moves. We also reset the per-dial connection state the loop writes, so this
dial starts clean and, critically, cannot resolve on a stale socket's
connected signal: replacing _streamConnected with a fresh deferred
discards an early un-gated socket's resolve so placeCall blocks until a
genuine socket authenticates; dropping _streamWs/_streamSid stops
sendAudio writing to the stale socket; clearing buffered inbound audio and
zeroing the frame counter keeps the stale socket's media out of this call;
clearing the terminal flag/reason stops its stop reading as this call
ending. Inbound/b-leg behaviour is unchanged: no socket has adopted yet when
the genuine one arrives, so it adopts under the current generation.
Internal
Internal
Disconnect-counter (#775 Tier 2b): how the media session ended.
The max-duration watchdog closes the socket itself, so the media loop's own
"close" verdict lands moments later and would otherwise mask WHY the call
ended — the cap's verdict wins ties. connect()/disconnect() assign the
field directly and so still clear it for the next session.
Internal
Internal
Internal
Default call() body, ported from Python VoiceAgentAdapter.call.
Threads the latest user-message audio through sendAudio, drains the agent response on tail silence, records one user and one agent segment into the executor state, and returns the merged assistant audio message. Subclasses may override for specialised flows but will usually inherit it.
Open the transport and prepare to exchange audio.
Close the transport and release resources.
Send a first-class interrupt signal to the agent under test.
Adapters that advertise capabilities.interruption === true override
this to send the transport-native interrupt (e.g. Twilio clear,
OpenAI Realtime response.cancel). The default raises
UnsupportedCapabilityError; callers (scenario.interrupt())
check capabilities.interruption and fall back to timing-based
barge-in when this returns false.
Whether the Media Stream transport is open (Gap #11). Also false once the
stream has ended (_streamEnded) even if disconnect() was never
called — a remote hangup (Twilio stop / socket close) must trip the
runtime's isConnected() gate on its own, not only after we tear down.
Exception: while a farewell recorded before the hangup is still sitting
in the inbound queue, this stays true so defaultVoiceCall drains it
instead of taking the agentHungUp early return and dropping it on the
floor. Once the queue holds nothing but the terminal sentinel (or is
empty), later turns see false and conclude via agentHungUp.
OptionalattachStream?: "a-leg" | "b-leg"Stream-attach mode. "b-leg" (default) rewrites the callee's voice_url —
owned numbers only. "a-leg" originates with inline <Connect><Stream> so
the stream rides our own leg and to can be any external number. Because
that reaches numbers this account does not own, to must appear in the
adapter's allowedCallees (deny-by-default) and the public base URL must
be reachable from the edge — both checked before origination.
OptionalattachStreamToSelf?: booleanLegacy stream-attach toggle. Superseded by attachStream: true/unset →
"b-leg", false → originator-only. Passing both with disagreeing intent
throws.
OptionalmaxCallDurationSeconds?: numberHow long the CALL may live, in seconds (a-leg only; default DEFAULT_MAX_CALL_DURATION_SECONDS, hard cap MAX_CALL_DURATION_CAP_SECONDS — a larger request throws).
Not to be confused with timeoutMs, which bounds how long we WAIT FOR
THE MEDIA STREAM TO CONNECT and says nothing about the call's length.
Under a-leg's <Connect> the call lives exactly as long as the
WebSocket, so without a ceiling a hung executor keeps a billing PSTN call
open forever. Two mechanisms enforce it: Twilio's own TimeLimit on
Calls.create (the load-bearing half — it still fires if this process
hangs or is killed) and an adapter-side wall-clock timer that hangs the
call up via REST and closes the socket.
Optionalrecord?: booleanRequest Twilio-side call recording (Record=true on Calls.create).
Stamped onto the voice.adapter.dial span when set so "was this call
recorded" is answerable from the trace alone.
OptionalstreamNonce?: stringCaller-supplied a-leg stream nonce, overriding this adapter's own
mintStreamNonce call. Exists for a host platform that runs the
media listener Twilio actually dials back in a SEPARATE process from
the one placing this call (e.g. a parent worker process fronting a pool
of scenario-execution child processes): that platform must register the
nonce with its own listener BEFORE Twilio can possibly connect, which
means it must know the nonce before placeCall runs — impossible if
this adapter mints it internally. Ignored outside a-leg mode, the only
mode a stream nonce means anything in.
OptionaltimeoutMs?: numberProduction entry for a host platform that accepts Twilio's public connection in a SEPARATE process from the one this adapter runs in (an a-leg call whose parent worker process owns the public listener and routes by nonce to the child that placed the call). That parent accepts the raw TCP upgrade, does NOT complete the WebSocket handshake, and hands the still-unhandshaked socket to this process over IPC — this method is where it arrives. Delegates to TwilioWebhookServer.receiveExternalUpgrade, which re-enters this adapter's ordinary upgrade path, so the a-leg nonce check and the media loop behave identically to a locally-received upgrade.
A call placed with no host-supplied placeCall({ streamNonce }) never
needs this: this adapter's own local server receives Twilio's dial-back
directly, the ordinary way.
Send DTMF digits on the live call (uses Twilio REST <Play digits>).
Refused in a-leg mode (#762 AC10) — see _assertDtmfSupported.
Abstract base for voice agents that exchange audio with the agent under test.
Subclasses must implement connect, disconnect, sendAudio, and receiveAudio. They must also publish an AdapterCapabilities instance as the capabilities field — declared once per concrete adapter, not per instance.
The default call implementation lives in defaultVoiceCall: it extracts audio from the latest user message, transmits via sendAudio, drains the agent response on tail silence, and records one user + one agent segment into the executor state. Subclasses can override
call()for specialised flows but will usually inherit it.