Skip to content

fix(react-native): Disconnecting voice conversations over WebSocket in RN - #622

Open
brojd wants to merge 36 commits into
elevenlabs:nextfrom
brojd:brojd/fix-websocket-disconnect-on-react-native
Open

fix(react-native): Disconnecting voice conversations over WebSocket in RN#622
brojd wants to merge 36 commits into
elevenlabs:nextfrom
brojd:brojd/fix-websocket-disconnect-on-react-native

Conversation

@brojd

@brojd brojd commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

Demo: https://youtu.be/tax9cbgDYMM

Fixes #605

Problem

WebSocket voice conversations disconnect immediately on React Native while working fine on web. The root cause: the WebSocket voice path uses MediaDeviceInput and MediaDeviceOutput from @elevenlabs/client, which rely on web-only APIs (AudioContext, AudioWorklet, HTMLAudioElement) that don't exist in React Native.

The React Native setup strategy (reactNativeSessionSetup) delegated to webSessionSetup for all connection types. This worked for WebRTC (where LiveKit's native polyfills handle audio), but crashed immediately for WebSocket voice because the web audio pipeline tried to instantiate browser APIs.

Solution

Introduce React Native-specific input and output controllers for the WebSocket path using react-native-audio-api:

  • Input (ReactNativeInputForWebSocket): Uses AudioRecorder connected to an AudioContext via a RecorderAdapterNode to capture microphone audio as float PCM, converts to 16-bit PCM, and dispatches to the WebSocket connection.

  • Output (ReactNativeOutputForWebSocket): Uses AudioBufferQueueSourceNode for gapless streaming playback. PCM chunks from the server are converted to AudioBuffer objects and enqueued for continuous, gap-free native playback.

The setup strategy now branches on connectionType:

  • "websocket": Uses the new RN-specific controllers with react-native-audio-api managing the audio session.
  • Default (WebRTC): Unchanged — configures LiveKit AudioSession and delegates to webSessionSetup.

Why react-native-audio-api?

Few options have been evaluated:

Approach Verdict
react-native-audio-api Chosen. Provides AudioBufferQueueSourceNode for gapless output streaming and AudioRecorder with onAudioReady callback for real-time mic input. Maintained by Software Mansion, MIT license.
expo-av Rejected for output — plays audio files, not streams. Each PCM chunk required writing a WAV file to disk, loading, playing, and cleaning up — causing choppy audio with gaps between chunks.
Custom native module Rejected — would require maintaining iOS (AVAudioEngine) and Android (AudioTrack) native code. react-native-audio-api provides the same capability from JS.
LiveKit MediaRecorder shim Rejected for input — the @livekit/react-native MediaRecorder shim only works with tracks from active WebRTC peer connections, not standalone getUserMedia streams.

Changes

@elevenlabs/client (patch):

  • VoiceConversation.ts: Wrap preliminary getUserMedia() in try/catch (non-fatal on RN). Guard document.addEventListener / document.removeEventListener with typeof document checks.
  • ConnectionFactory.ts: Add function overloads so createConnection({ connectionType: "websocket" }) returns Promise<WebSocketConnection> without casts.
  • internal.ts: Export attachInputToConnection, attachConnectionToOutput, createConnection, and related types for the RN package.

@elevenlabs/react-native (patch):

  • New ReactNativeInputForWebSocket — mic capture via react-native-audio-api
  • New ReactNativeOutputForWebSocket — gapless audio playback via react-native-audio-api
  • Updated index.react-native.ts — branch setup strategy on connectionType
  • Added react-native-audio-api as optional peer dependency

Example app:

  • Added react-native-audio-api dependency
  • Updated metro.config.js for monorepo resolution
  • Native project updates from expo prebuild (adding react-native-audio-api native module)

Note

Medium Risk
Introduces a new React Native audio capture/playback pipeline for WebSocket voice using react-native-audio-api and changes session setup branching, which can affect microphone permissions, audio session behavior, and streaming stability on mobile.

Overview
Fixes React Native WebSocket voice sessions disconnecting by adding RN-specific input/output controllers (mic capture via AudioRecorder and gapless playback via AudioBufferQueueSourceNode) and routing the RN setup strategy to use them when connectionType: "websocket".

@elevenlabs/client is updated to be more RN-tolerant (non-fatal preliminary getUserMedia permission warmup, typed createConnection overloads) and to export additional internal helpers (createConnection, attachInputToConnection, attachConnectionToOutput, and related types) consumed by the RN package.

The Expo RN example is updated to include react-native-audio-api and Metro singleton resolution, and removes committed native ios/ and android/ project artifacts (with .gitignore adjusted accordingly).

Written by Cursor Bugbot for commit ee24197. This will update automatically on new commits. Configure here.

kraenhansen and others added 23 commits February 24, 2026 15:16
…abs#525)

* Migrate client package to TypeScript compiler for ESM builds

Replaces microbundle with TypeScript compiler for building ESM and type definitions. This eliminates CommonJS support as a breaking change.

Changes:
- Add tsconfig.build.json and tsconfig.test.json with project references
- Update package.json exports to use dist/index.js for ESM
- Remove "main" and "module" fields from package.json
- Keep microbundle only for UMD bundle (unpkg)
- Update build scripts to use tsc for ESM and microbundle for UMD

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Fix react package Jest config to consume ESM client package

- Configure ts-jest with useESM: true and module: esnext
- Add moduleNameMapper to resolve @elevenlabs/client to ESM dist

This allows the react package tests to properly consume the client
package now that it only exports ESM (no CommonJS).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Update client package TypeScript target to ES2022

Bumps target and lib from ES2018 to ES2022 to take advantage of newer
JavaScript features like top-level await, class fields, and private
methods.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Use 'default' export condition instead of 'import'

Since the package only provides ESM (no CommonJS), using 'default'
as a catch-all is more appropriate than the specific 'import'
condition. Order matters in export conditions, so 'default' is
placed last as the fallback.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Pin module system to ES2022 and resolution to "bundler"

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
* fix(convai-widget): patch livekit-client to handle frozen RTCPeerConnection prototype

Wix's security hardening freezes RTCPeerConnection.prototype.addEventListener
as read-only, causing the widget to crash on load. livekit-client bundles
webrtc-adapter inline, which monkey-patches addEventListener at module
evaluation time.

Add a pnpm patch that inserts a writability guard (try/catch no-op assignment)
before the monkey-patch. If the prototype is frozen, the patching is skipped
silently — this is safe since the shims are legacy cross-browser compat code
that modern browsers don't need.

* chore: add changeset for wix widget compat fix

* Update patches/livekit-client@2.16.0.patch

Co-authored-by: Kræn Hansen <kraen@elevenlabs.io>

* fix: regenerate livekit-client patch to fix hunk header integrity check

---------

Co-authored-by: Kræn Hansen <kraen@elevenlabs.io>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…enlabs#550)

Microbundle's --generateTypes defaults to true when a `types` field
exists in package.json, causing it to overwrite dist/index.d.ts after
tsc emits it. This stripped the sourceMappingURL reference, orphaning
the .d.ts.map file and breaking IDE "Go to Definition".

Adding --no-generateTypes ensures tsc remains the sole source of truth
for type declarations.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…bs#551)

Applies the same migration pattern from PR elevenlabs#525 (@elevenlabs/client) to
@elevenlabs/react: tsc now handles ESM output and type declarations, while
microbundle is kept only for the UMD bundle. Drops CJS export (same
breaking change as the client migration).

- Replace monolithic tsconfig.json with project references root
- Add tsconfig.build.json (composite, ES2022, react-jsx, declarations)
- Add tsconfig.test.json (extends build, noEmit, jest types)
- Update package.json: split build:esm/build:umd scripts, update exports
  to use `default` entry, add `files: ["dist"]`, remove legacy main/module/source fields
- Update jest.config.cjs: jsx: 'react' → 'react-jsx'

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Migrate react package tests from jest to vitest

Replaces jest + ts-jest + jest-environment-jsdom with vitest, matching
the test runner used by the client package.

- Add vitest.config.ts with jsdom environment (no browser mode)
- Migrate src/index.test.ts: jest.mock/jest.fn → vi.mock/vi.fn,
  jest.Mock cast → as unknown as Conversation
- Update package.json: test script jest → vitest, remove @types/jest /
  jest / jest-environment-jsdom / ts-jest, add vitest ^3.0.5
- Remove jest.config.cjs
- Update tsconfig.test.json: types jest → vitest/globals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Empty commit to kick off CI

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add `InputController` interface and implement it (elevenlabs#513)

Add InputController interface and implement it

* Make `Input` implement `EventTarget` (elevenlabs#518)

* Make Input implement EventTarget

* Export InputEventTarget type from input.ts

Add a named type for the event-target surface of Input, providing
a reusable interface for components that need to subscribe to
input events without depending on the full Input class.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Rename addEventListener/removeEventListener to addListener/removeListener

Based on PR elevenlabs#518 feedback, simplified the InputEventTarget API by:
- Renaming addEventListener(type: "input", listener) to addListener(listener)
- Renaming removeEventListener(type: "input", listener) to removeListener(listener)
- Removing the type parameter since there's only one event type ("input")

This makes the API simpler and more intuitive while maintaining the same functionality.

Updated:
- InputEventTarget type definition in input.ts
- Input class methods in input.ts
- VoiceConversation call sites (constructor and changeInputDevice)

All tests passing (124 client tests, 87 convai-widget-core tests).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* Extract attachInputToConnection function (elevenlabs#519)

* Extract attachInputToConnection function

Move input-to-connection routing logic from VoiceConversation into
a standalone attachInputToConnection function. This decouples the
conversation class from encoding/sending details.

Changes:
- New attachInputToConnection.ts with routing logic
- VoiceConversation now uses detachInput teardown function
- Removes onInputWorkletMessage method from VoiceConversation
- Teardown called before closing connection in handleEndSession

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add back TODO comment about maxVolume

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* Hide Input's internal fields (worklet, context, inputStream) (elevenlabs#521)

Makes worklet, context, and inputStream private in the Input class to
enforce using the public API (InputController + InputEventTarget).

- Changed worklet, context from public readonly to private readonly
- Changed inputStream from public to private
- Updated tests to assert behavior instead of internal state
- Removed assertions on input.inputStream and output.audioElement
- All tests pass (client: 124, widget-core: 87)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* Rename Input to MediaDeviceInput and use interface types (elevenlabs#523)

Rename `Input` to `MediaDeviceInput` and use interface types

- Renamed Input class to MediaDeviceInput (internal implementation)
- Changed input property type to InputController & InputEventTarget
- Changed changeInputDevice() to return void for better encapsulation
- Added error handling for optional analyser field
- Removed Input export from package (use InputController interface)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add integration test for MediaDeviceInput listener API (elevenlabs#535)

Verifies that addListener receives audio data from the worklet,
which requires port.start() to be called in the constructor.
Uses real browser audio pipeline with fake media streams instead
of mocks, catching the regression if port.start() is removed.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Implement InputController for WebRTC and platform setup strategy (elevenlabs#524)

* Implement InputController for WebRTC and platform setup strategy

Milestones 6 & 7: Completes the input abstraction by making
WebRTCConnection implement InputController and extracting platform-
specific session setup logic.

**Breaking API Changes:**
- InputConfig: `inputDeviceId` → `deviceId` (removes redundant "input" prefix)
- New InputDeviceConfig type (deviceId, preferHeadphonesForIosDevices) in InputController
- InputConfig now extends InputDeviceConfig and adds onError callback (in input.ts)
- InputController.setInputDevice() uses `Partial<FormatConfig> & InputDeviceConfig`

**WebRTCConnection as InputController:**
- Implements InputController interface (not InputEventTarget - LiveKit handles audio routing)
- Added setInputDevice(config), setMuted, isMuted getter, analyser (undefined for WebRTC)
- Made close() async to match interface
- Tracks mute state internally with _isMuted field
- setInputDevice() throws error if sampleRate/format/preferHeadphonesForIosDevices are provided

**Platform Setup Strategy:**
- Created platform/VoiceSessionSetup.ts with pluggable strategy pattern
- webSessionSetup branches on connection type:
  - WebSocket: creates MediaDeviceInput, attaches to connection
  - WebRTC: uses connection itself as InputController
- Returns { input: InputController, output: Output, detachInput }

**VoiceConversation Simplifications:**
- Input property is now InputController (not MediaDeviceInput)
- setMicMuted() delegates to input.setMuted() (no instanceof checks)
- changeInputDevice() passes config to input.setInputDevice()
- Uses setupStrategy for platform-agnostic initialization
- Zero knowledge of MediaDeviceInput or WebRTC-specific details

**Build Fix:**
- Added runtime export to InputController.ts to work around microbundle tree-shaking
- Pure type-only modules get excluded from builds, preventing .d.ts generation
- Will be removed when migrating from microbundle to TypeScript compiler

Prepares for React Native support where a different strategy will be used.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Rename muting methods to setInputMuted (elevenlabs#524)

Breaking changes:
- InputController.setMuted() renamed to setInputMuted() and now returns Promise<void>
- MediaDeviceInput.setMuted() renamed to setInputMuted()
- WebRTCConnection.setMicMuted() renamed to setInputMuted()

Internal changes:- BaseConversation.setMicMuted() now throws an error for text-only conversations
- VoiceConversation.setMicMuted() delegates to InputController.setInputMuted() with error handling

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Refactor WebRTCConnection to expose input as property

Instead of WebRTCConnection implementing InputController directly, it now
exposes a readonly `input` property that implements the interface. This
resolves the namespace collision between:
- WebRTCConnection.close() - closes the entire connection
- InputController.close() - closes only input resources

Changes:
- WebRTCConnection now has `public readonly input: InputController` property
- Input controller initialized in constructor with arrow functions capturing 'this'
- Changed InputController.isMuted from property to function to support arrow function pattern
- Updated MediaDeviceInput.isMuted to be a function
- Updated VoiceSessionSetup to use connection.input instead of connection
- Removed redundant InputController methods from WebRTCConnection (now on input property)

This provides better separation of concerns - the input controller is now a
facet of the connection rather than the connection itself.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Remove microbundle workaround from InputController

The __inputControllerModule export was a workaround for microbundle's
tree-shaking of type-only modules. Now that we've migrated to the
TypeScript compiler, this workaround is no longer needed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Revert deviceId rename, keep as inputDeviceId for now

Reverting the InputConfig.inputDeviceId → deviceId rename to reduce
breaking changes in this PR. The config field remains as inputDeviceId.

Future improvement: namespace input/output configs under "input" and
"output" properties, which would then allow removing the "input" and
"output" prefixes from deviceId fields.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Remove implementation-specific comments from generic code

Removes comments mentioning concrete implementations (MediaDeviceInput,
WebRTCConnection) from generic/interface-level code to keep the
abstraction clean.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* Rename InputController methods to remove 'Input' prefix (elevenlabs#529)

Since the methods are already namespaced under the 'input' property,
remove the redundant 'Input' prefix from method names:
- setInputDevice -> setDevice
- setInputMuted -> setMuted

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* Extract OutputController interface and refactor audio routing (elevenlabs#533)

* Extract OutputController interface and refactor audio routing

Separate OutputController (control interface) from audio data flow,
mirroring the InputController pattern. WebSocketConnection now implements
OutputEventTarget for audio data routing, while MediaDeviceOutput handles
playback state and interrupt logic internally.

Key changes:
- New OutputController interface with control-only methods
- MediaDeviceOutput encapsulates interrupt state and playback
- WebSocketConnection emits audio events via OutputEventTarget
- Audio routing moved to VoiceSessionSetup via attachConnectionToOutput
- VoiceConversation simplified: no more direct worklet access or as-any casts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Change analyser from readonly property to getAnalyser() method

This eliminates the IIFE and "const self = this" pattern in
WebRTCConnection's output property initializer. With getAnalyser()
being a method, arrow functions in the object literal capture `this`
naturally, so the output controller can use the same simple pattern
as the input controller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Encapsulate VoiceConversation internal fields (elevenlabs#537)

* Encapsulate VoiceConversation internal fields

Make input, output, playbackEventTarget, cleanUp, and wakeLock private to prevent external access and enforce proper encapsulation of VoiceConversation's internal state.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Remove direct wakeLock property checks from tests

Test behavior (mock calls) instead of implementation details (private fields). This follows testing best practices and avoids coupling tests to internal implementation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>

* Remove `outputDeviceId` from `FormatConfig` (elevenlabs#543)

Remove outputDeviceId from FormatConfig

FormatConfig should only describe the audio format (codec + sample rate).
The outputDeviceId field was already provided by OutputDeviceConfig /
OutputConfig in every intersection type that needed it, making its presence
on FormatConfig redundant and confusing — it leaked into changeInputDevice's
parameter type, making it look like an output device ID was relevant there.

Removes outputDeviceId from FormatConfig and the React package's
DeviceFormatConfig. Updates changeOutputDevice in the React hook to accept
DeviceFormatConfig & OutputConfig so outputDeviceId is still accepted via
the correct type.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Make `BaseConversation` abstract; replace `Conversation` class with type union (elevenlabs#538)

* Make BaseConversation an abstract class

Move voice-related method stubs (setVolume, setMicMuted,
getInputByteFrequencyData, getOutputByteFrequencyData, getInputVolume,
getOutputVolume) from concrete implementations in BaseConversation to
abstract declarations. TextConversation now provides explicit
implementations (no-ops/throws) instead of silently inheriting stubs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replace abstract Conversation class with type union and object factory

- `Conversation` is now `type Conversation = TextConversation | VoiceConversation`
  instead of an abstract class extending `BaseConversation`
- `Conversation.startSession()` is now a method on a plain const object,
  routing via `isTextOnly()` rather than inheriting `getFullOptions()`
- Fix: `createConnection` in both subclass `startSession` methods now receives
  `fullOptions` instead of raw `options`, ensuring `overrides.conversation.textOnly`
  is always populated before the connection is established

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Throw in TextConversation.setVolume and setMicMuted

Silent no-ops could leave audio-related UI in an unexpected state.
Throwing makes it explicit that these operations are not supported
in text-only conversations.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Update test to expect setVolume to throw on text conversation

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Add and update changesets for all breaking changes

- Rename auto-generated changeset names to semantic names
- Upgrade all changeset bump types from minor to major
- Add before/after examples and migration steps to each changeset
- Add missing changesets for: Output class removal, wakeLock private,
  Conversation class → namespace, and @elevenlabs/react outputDeviceId removal

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
)

* Add ConversationProvider with lifecycle management and mergeOptions utility (elevenlabs#560)

* Add ConversationProvider skeleton with raw context and lifecycle management

Introduces the foundational ConversationProvider component and ConversationContext
for the new granular context-based React API. Includes stable ref-backed callbacks,
session lifecycle management (start/end/cancel), and useRawConversation as a public
escape hatch for advanced use cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add generic mergeOptions utility with deep merge and function composition

Introduce a reusable mergeOptions<T> function in the client package that
deep-merges plain objects, composes functions (all fire in order), and
uses last-wins for other values. Use it in ConversationProvider to merge
default props, stable callbacks, and per-session options so that
callbacks provided both via props and startSession() both fire.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove unused awaits of empty promises

* Use Promise.withResolvers() in ConversationProvider tests

Replaces the manual resolver pattern (let resolve!; new Promise(r => resolve = r))
with the cleaner built-in Promise.withResolvers() API.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Add eslint-plugin-react-hooks to react package (elevenlabs#568)

* Add eslint-plugin-react-hooks to react package

Replaces the empty ESLint config with the official recommended flat
config for react-hooks. Catches exhaustive-deps and rules-of-hooks
violations. Ignores dist/.

Also updates react-dom to 19.2.4 to match the resolved react version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix lint violations from typescript-eslint and react-hooks rules

Replace `any` with proper types in useStableCallbacks and device
change casts, and suppress intentional ref-during-render patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Expose conversationRef from ConversationContext (elevenlabs#572)

Sub-providers (Controls, Input, Feedback) each duplicated a local ref
mirroring ctx.conversation to build stable callbacks. Expose the
existing conversationRef through the context so they can use it
directly, eliminating the repeated ref + eslint-disable pattern.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Add ConversationControlsProvider with stable action references (elevenlabs#562)

* Add ConversationControlsProvider with stable action references

- ConversationControlsProvider reads ConversationContext, mirrors the
  conversation instance in a ref, and builds stable useCallback delegates
  for all action methods (sendUserMessage, setVolume, changeInputDevice, etc.)
- Methods throw "No active conversation" when called without an active session
  rather than silently no-oping, making misuse easier to catch
- changeInputDevice/changeOutputDevice use instanceof VoiceConversation to
  give a clear error for text-only conversations
- ConversationProvider updated to render <ConversationControlsProvider> as a
  child — no state or logic added to the Provider itself
- All control callbacks are stable: the context value reference never changes,
  preventing re-renders in consumers when conversation state changes

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Fix lint error: expand useMemo deps instead of disabling eslint rule

The react-hooks/exhaustive-deps rule is not configured in the project's
eslint config, so the eslint-disable comment caused a lint error in CI.
All callbacks are stable useCallback refs so listing them explicitly is
safe and removes the need for the suppression comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix react-hooks lint violations in ConversationControls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use conversationRef from context in ConversationControls

Replace the local ref + eslint-disable pattern with the shared
conversationRef now available from ConversationContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Add ConversationStatus context + callback registry (elevenlabs#565)

* Add ListenerSet and ListenerMap utilities for callback composition

ListenerSet<Args> is a generic typed multicast delegate that holds a
Set of listeners and provides add (returns a removal function), invoke,
and size. It is parameterized by the argument tuple type.

ListenerMap<T> maps named callback keys to ListenerSet instances.
- register(callbacks) adds listeners for one or more keys and returns
  a single removal function that unsubscribes all of them.
- compose() returns a Partial<T> where each key fans out to all
  registered listeners. Composed functions reflect live listener state,
  so additions/removals after compose still take effect.
- Uses a never[] constraint to accept any function signature without
  contravariance issues, and an assertFunction guard to reject
  undefined values at runtime.

Both utilities are fully tested in isolation with vitest.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add ConversationStatus context, registerCallbacks, and ListenerMap wiring

ConversationContext now exposes registerCallbacks(Partial<Callbacks>),
allowing sub-providers to declare callback handlers that are composed
into the Conversation.startSession() call via ListenerMap.compose().

ConversationStatusProvider registers onStatusChange and onError:
- Maps client Status values to "disconnected" | "connecting" |
  "connected" | "error" (dropping transient "disconnecting")
- Tracks an optional error message from onError, cleared on
  any subsequent non-error status transition
- Exports useConversationStatus() hook

Also updates test files to use destructured mock access pattern:
  const [[opts]] = vi.mocked(Conversation.startSession).mock.calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix exhaustive-deps lint warning in ConversationStatus

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Co-locate ConversationStatusValue type with its provider

Move ConversationStatusValue from types.ts into ConversationStatus.tsx
for consistency with ConversationControlsValue and ConversationInputValue
which are already co-located with their providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Pre-initialize ListenerMap with CALLBACK_KEYS from @elevenlabs/client

Export a CALLBACK_KEYS runtime array from @elevenlabs/types (co-located
with the Callbacks type) through @elevenlabs/client. Use it in
ConversationProvider to pre-initialize all ListenerMap sets, ensuring
compose() returns functions for every callback key — even for listeners
registered after compose was called.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Narrow ListenerMap constructor keys type to keyof T & string

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address Copilot review feedback on PR elevenlabs#565

- Lazy-initialize ListenerMap via useState to avoid re-construction on every render
- Skip undefined values in ListenerMap.register to match Partial<T> type contract
- Export ConversationProvider, useConversationStatus, and ConversationStatusValue from public entrypoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Revert public exports of ConversationProvider and useConversationStatus

Not ready to be part of the public API yet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add compile-time exhaustiveness check for CALLBACK_KEYS

Ensures CALLBACK_KEYS stays in sync with the Callbacks type via a
type-level Exclude check and a runtime duplicates guard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix useConversationStatus doc comment to reflect actual render behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Throw on unknown callback keys in ListenerMap.register

Since all keys are pre-initialized in the constructor, a missing key
indicates a typo or misuse. Throwing early surfaces these mistakes
instead of silently dropping callbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Change ListenerMap.compose return type from Partial<T> to T

All keys are pre-initialized in the constructor, so compose() always
produces every key. Returning T instead of Partial<T> reflects this
and eliminates the doc/type contradiction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address latest Copilot review feedback

- Add listenerMap to useCallback dependency arrays (stable, silences lint)
- Use `as const satisfies` for CALLBACK_KEYS to preserve literal tuple type
- Fix compile-time exhaustiveness check to use AssertNever pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Depend on stable registerCallbacks instead of ctx in ConversationStatus

Avoids unnecessary unsubscribe/re-subscribe cycles when the context
value changes due to session lifecycle events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract useRegisterCallbacks hook for sub-provider callback registration

Provides a ref-based hook that registers stable callback wrappers once,
delegating to the latest callback values without re-subscribing. Simplifies
ConversationStatusProvider and establishes the pattern for future sub-providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Memoize ConversationStatusContext value to avoid unnecessary consumer re-renders

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Re-subscribe useRegisterCallbacks when the set of active keys changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove unused eslint-disable for react-hooks/refs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use useLayoutEffect in useRegisterCallbacks to catch synchronous events

startSession fires onStatusChange synchronously, so callbacks must be
registered before paint to avoid missing the initial status event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use stable scalar dep instead of spreading activeKeys in useRegisterCallbacks

Replace ...activeKeys spread in the effect dep array with
activeKeys.join("|") — a fixed-length dep list that's easier to
reason about. The effect still closes over activeKeys directly,
so no split/reconstruction is needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix misleading comment in ListenerMap test about post-removal behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Add ConversationInput context + useConversationInput hook (elevenlabs#566)

* Add ConversationInput context with useConversationInput hook

setMuted throws when called without an active session, matching the
established pattern in ConversationControls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix react-hooks/refs lint violation in ConversationInput

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use conversationRef from context in ConversationInput

Replace the local ref + eslint-disable pattern with the shared
conversationRef from ConversationContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add useRawConversationRef hook and apply React.PropsWithChildren

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Reset isMuted state on session disconnect

Register an onDisconnect callback to reset isMuted to false when the
conversation ends, preventing stale mute state across sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add tests for useRawConversationRef hook

Cover the throw-outside-provider and returns-ref-from-context cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Clean up conversation test wrappers (elevenlabs#567)

Remove unnecessary signedUrl prop (mocked, never used) and replace
{ children: React.ReactNode } with React.PropsWithChildren.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Add ConversationMode context with useConversationMode hook (elevenlabs#569)

* Add ConversationMode context with useConversationMode hook

Registers onModeChange via registerCallbacks and exposes mode,
isSpeaking, and isListening. Wired into the ConversationProvider
sub-provider stack.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Reset mode to listening on session disconnect

Register an onDisconnect callback to reset mode state, preventing stale
speaking/listening state across sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Add ConversationFeedback context with useConversationFeedback hook (elevenlabs#571)

* Add ConversationFeedback context with useConversationFeedback hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Reset canSendFeedback on session disconnect

Register an onDisconnect callback to reset canSendFeedback to false,
preventing stale feedback state across sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Sync ConversationProvider state on external disconnect (elevenlabs#574)

Register an internal onDisconnect listener via the listener map so the
provider clears its conversation ref and state when the session ends
externally (agent hangs up, raw endSession call, etc.). Composes with
user-provided onDisconnect callbacks.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Export conversation context API, fix circular dependency, add import/no-cycle (elevenlabs#575)

* Export conversation context API from @elevenlabs/react

Add public exports for ConversationProvider, all context hooks
(useConversationControls, useConversationStatus, useConversationInput,
useConversationMode, useConversationFeedback, useRawConversation),
and their associated value types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replace DeviceFormatConfig/DeviceInputConfig with client types

These were duplicates of FormatConfig and InputDeviceConfig already
exported by @elevenlabs/client. Also re-exports InputDeviceConfig and
OutputDeviceConfig for symmetry, inlines ConversationProviderProps into
ConversationProvider.tsx, and deletes the now-empty types.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Move location utilities to @elevenlabs/client

parseLocation, getOriginForLocation, getLivekitUrlForLocation and the
Location type are not React-specific — they map a region string to
client-level origin/livekitUrl values. Moving them to the client
package breaks the circular dependency between index.ts and
ConversationProvider.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add eslint-plugin-import-x with import-x/no-cycle to react and client packages

Enforces no circular dependencies at lint time. Also migrates the client
eslint config from CJS to ESM (matching the package's "type": "module").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replace eslint-plugin-import-x with eslint-plugin-import for defineConfig compatibility

eslint-plugin-import-x has type incompatibilities with eslint's defineConfig
(un-ts/eslint-plugin-import-x#421). Switch to the
original eslint-plugin-import with eslint-import-resolver-typescript, and
configure import/extensions + import/parsers for TypeScript file resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add typescript-eslint to client eslint config

Configure @eslint/js recommended and typescript-eslint recommended so
that eslint can parse .ts files and import/no-cycle actually detects
cycles. Suppress pre-existing lint violations to keep the change focused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove redundant import/parsers setting from eslint configs

typescript-eslint already sets @typescript-eslint/parser via
languageOptions.parser, making the import/parsers setting unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update packages/client/src/utils/location.ts

Co-authored-by: Paul Asjes <paul.asjes@elevenlabs.io>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Paul Asjes <paul.asjes@elevenlabs.io>

* Migrate testbench to SDK conversation providers (elevenlabs#577)

* Migrate testbench to SDK conversation providers

Replace the local conversation-provider.tsx prototype with imports from
@elevenlabs/react. Updates hook and method names to match the SDK API:
start→startSession, end→endSession, useConversationMicrophone→
useConversationInput, isMicMuted→isMuted, setMicMuted→setMuted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Wrap ConversationProvider in ClientOnly to avoid SSR warnings

ConversationProvider uses useLayoutEffect internally, which triggers
warnings when rendered on the server in TanStack React Start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Rename logged method from "start" to "startSession" and fix deps array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Clean up ConversationProvider sub-providers (elevenlabs#584)

* Replace nested provider pyramid with reduceRight composition

Use a module-level SUB_PROVIDERS array and reduceRight to compose
sub-providers, making it trivial to add new providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove redundant type annotations on useRegisterCallbacks arguments

The Callbacks type already provides full parameter typing, so inline
annotations on callback arguments are unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use method shorthand in useRegisterCallbacks calls

Replace arrow function syntax with method shorthand for less verbosity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Empty commit to trigger CI

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Preserve callback-presence semantics in composed callbacks (elevenlabs#586)

* Preserve callback-presence semantics in useStableCallbacks and ListenerMap.compose()

The client uses callback presence as feature guards (e.g. onUnhandledClientToolCall
triggers early return in handleClientToolCall). Previously, both useStableCallbacks
and ListenerMap.compose() provided no-op wrappers for every callback key, defeating
these guards. Now both only include keys that actually have handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address review comments on callback-presence PR

- Update ListenerMap doc comment to clarify compose() omits empty keys
- Fix eslint-disable comment referencing non-existent variable
- Add assertion that unprovided callbacks are undefined in startSession options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Reimplement useConversation from granular hooks (elevenlabs#585)

* Reimplement useConversation from granular hooks (elevenlabs#585)

Replace the standalone useConversation with a thin wrapper that composes
useConversationControls, useConversationStatus, useConversationInput,
useConversationMode and useConversationFeedback. This removes ~250 lines
of duplicated session-management logic.

BREAKING CHANGES:
- useConversation now requires a <ConversationProvider> wrapper
- startSession returns void (was Promise<string>) — use getId() instead
- endSession returns void (was Promise<void>)
- micMuted prop renamed to isMuted in the return value (controlled
  micMuted prop is still accepted as input)
- ControlledState type removed from exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Forward hook options to startSession and add callback composition tests

Extract useStableCallbacks for reuse, register hook callbacks via
useRegisterCallbacks, and forward non-callback session config (e.g.
agentId) as defaults to startSession. Add tests covering callback
composition across provider, hook, and startSession layers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add tests for useStableCallbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix controlled micMuted/volume sync on connect

Depend on the reactive conversation value from useRawConversation()
instead of the stable conversationRef, so micMuted and volume effects
re-fire when a session connects. Add test for micMuted set before
startSession.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Export UseConversationOptions type from public API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Add changesets for react conversation API (elevenlabs#587)

* Add changesets for react conversation API changes

Add two changesets covering the externally-facing changes in the react
refactor:
- major: useConversation now requires ConversationProvider
- minor: new granular conversation hooks for render optimization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replace AudioVisualizer with ModeIndicator in changeset example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Paul Asjes <paul.asjes@elevenlabs.io>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Replace microbundle with rolldown/tsc

- Client + React: replace microbundle UMD build with rolldown IIFE
  (functionally equivalent for CDN/script-tag consumers)
- React-native: drop microbundle entirely, use tsc for compilation
- Rename build:umd → build:iife, output file lib.umd.js → lib.iife.js
- Update unpkg export paths accordingly

Resolves elevenlabs#599.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove jest and tests from @elevenlabs/react-native

The react-native package is being replaced by re-exports from
@elevenlabs/react. Remove jest config, test files, and test-related
devDependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Address review feedback for react-native package config

- Add "composite": true to tsconfig.build.json (consistent with other packages)
- Add "type": "module" to package.json (ESM output needs ESM package type)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add changeset for microbundle → rolldown migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add setClient internal API to @elevenlabs/client

Replace per-session overrides.client mechanism with a module-level
setClient() function exported from @elevenlabs/client/internal.
Wrapper packages call setClient at import time to register their
identity, removing the need to pass source info through session
config overrides.

- Add ./internal subpath export with setClient/client
- Remove overrides.client from public BaseSessionConfig type
- Always emit source_info in conversation initiation data
- React package calls setClient in index.ts
- Widget-core calls setClient in index.ts
- Update client tests for always-present source_info

Note: react UMD build (microbundle) fails because its bundled
TypeScript 4.9.5 cannot resolve subpath exports. Tracked separately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Rename setClient to setSourceInfo and simplify internal export

Rename Client → SourceInfo, client → sourceInfo, setClient →
setSourceInfo to match the source_info override field name. Simplify
the ./internal export to a plain string path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Freeze sourceInfo to make it read-only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove redundant overrides spread in widget-core conversation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Move internal utilities from client public API to internal entrypoint

Move CALLBACK_KEYS, mergeOptions, parseLocation, getOriginForLocation,
and getLivekitUrlForLocation from @elevenlabs/client to
@elevenlabs/client/internal. Update @elevenlabs/react imports accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Move Location type to internal entrypoint and update changeset

Move the Location type from @elevenlabs/client public API to
@elevenlabs/client/internal alongside the location utility functions.
Update changeset to reflect these are internal utilities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Clarify changeset: location utilities were removed, not moved

These were originally defined in @elevenlabs/react's index.ts, not
re-exported from @elevenlabs/client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove unreleased location utilities from changeset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Keep Location type in client public API

Location is part of the public type surface (used by HookOptions in
@elevenlabs/react), so it should stay in @elevenlabs/client rather
than @elevenlabs/client/internal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add useConversationClientTool hook to @elevenlabs/react

Enables React components to dynamically register client tools via a hook.
Uses a Proxy-backed mutable target so tools added/removed after session
start are immediately visible to BaseConversation at call time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use function declarations for ClientTools generic type and add tests

Change the useConversationClientTool generic from a single TParams type
to a ClientTools map of function declarations, enabling type-safe tool
name constraints and handler param/return inference. Add unit tests for
registration, unmount cleanup, latest-ref delegation, and name changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add changeset for useConversationClientTool hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replace Proxy with registry pattern for client tools

- Remove no-op Proxy (createClientToolsProxy) that just delegated to Reflect
- Use registry (Map) + live ref pattern: fresh clientTools object per session,
  hook-registered tools survive across sessions via registry
- Add duplicate detection: hook-vs-hook at registration, hook-vs-option at
  startSession
- Extract buildClientTools() to keep ConversationProvider minimal
- Export ClientTool type from index.ts
- Fix JSDoc for useConversationClientTool TTools type param

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix changeset text and use Object.hasOwn for duplicate detection

- Update changeset to describe registry pattern instead of Proxy
- Use Object.hasOwn instead of `in` operator to avoid matching
  inherited keys like toString/constructor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
… support (elevenlabs#580)

* Add React Native platform entrypoint to @elevenlabs/client

Adds a `react-native` export condition that calls `registerGlobals()`
from `@livekit/react-native` before re-exporting the main index. This
polyfills WebRTC globals needed by `livekit-client` in React Native
environments. Also adds `@livekit/react-native` as an optional peer
dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Export ConversationStatus type from @elevenlabs/react

Adds a `ConversationStatus` type alias for the status union
("disconnected" | "connecting" | "connected" | "error") used by
`ConversationStatusValue`. This makes the status type directly
importable without having to extract it from the value type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replace @elevenlabs/react-native implementation with @elevenlabs/react re-export

Replaces the custom LiveKit-based implementation with a simple
re-export of @elevenlabs/react. The package now serves as a convenience
wrapper with explicit peer dependencies on @livekit/react-native and
@livekit/react-native-webrtc, and a dedicated README with React Native
setup instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Migrate expo example to @elevenlabs/react conversation providers

Replace @elevenlabs/react-native usage with granular hooks from
@elevenlabs/react (ConversationProvider, useConversationControls,
useConversationStatus, useConversationInput, useConversationMode,
useConversationFeedback). Add web platform support via react-native-web.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add React Native voice session setup and fix expo example for Android

- Refactor VoiceSessionSetupStrategy to own connection creation, removing
  the separate preConnectionSetup hook
- Add React Native session setup: configures AudioSession before connecting,
  stops it on cleanup
- Enable microphone on SignalConnected in WebRTCConnection (matching
  useLiveKitRoom behaviour) so the server can establish the subscriber PC
- Stop preliminary getUserMedia stream before connecting to avoid blocking
  LiveKit media negotiation on React Native
- Restore @livekit/react-native-expo-plugin and @config-plugins/react-native-webrtc
  in expo example (required for LiveKitReactNative.setup on Android)
- Add metro.config.js with resolveRequest to deduplicate singleton packages
  across pnpm workspace boundaries
- Pass connectionType: "webrtc" explicitly in expo example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Re-export only conversation API from @elevenlabs/react-native

Replace blanket `export * from "@elevenlabs/react"` with explicit
conversation-related exports, excluding scribe. Update expo example
to import from @elevenlabs/react-native instead of @elevenlabs/react.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add setClient call to @elevenlabs/react-native

Call setClient after importing from @elevenlabs/react to override
the source from "react_sdk" to "react_native_sdk". Add
@elevenlabs/client as a direct dependency for the /internal import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Move reactNativeSessionSetup into @elevenlabs/react-native

Move the React Native voice session setup strategy (AudioSession
management) from @elevenlabs/client into @elevenlabs/react-native.
The client's react-native entrypoint now only polyfills WebRTC globals.

Add export conditions to @elevenlabs/react-native:
- "browser" → index.js (re-exports only)
- "react-native" → index.native.js (with session setup)
- "default" → index.js (fallback)

Extract sourceInfo into its own file to break the circular dependency
between internal.ts and VoiceSessionSetup.ts, then re-export
setSetupStrategy and webSessionSetup from @elevenlabs/client/internal.

Remove setSetupStrategy and related types from @elevenlabs/client
public API since they are internal implementation details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove React Native entrypoint from @elevenlabs/client

The registerGlobals call and session setup now live in
@elevenlabs/react-native, so the client package no longer needs
a react-native export condition or @livekit/react-native peer dep.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix TextInput focus loss on web in expo example

Remove TouchableWithoutFeedback wrapper that was calling
Keyboard.dismiss() on every tap, preventing the TextInput
from receiving focus on web.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Rename index.native.ts to index.react-native.ts to fix circular import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add WebRTC/WebSocket connection type toggle in expo example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add changesets for react-native rewrite and react type export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix test failures: getUserMedia timing and WebRTC mock

- Move preliminary getUserMedia stream stop back to after setupStrategy
  completes — MediaDeviceInput.create needs mic access still granted
- Add SignalConnected event and once() to WebRTCConnection test mock to
  match the new mic-enable-on-signal-connected behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Upgrade react-native-web to ~0.21.2 in expo example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Simplify package READMEs to defer to online documentation

Replace verbose API reference documentation in all three package READMEs
with concise quick-start examples and links to the official docs. The
react-native README is rewritten to reflect the new architecture (thin
wrapper re-exporting from @elevenlabs/react with platform setup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Restore "TypeScript" naming in client README title

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Mention JavaScript alongside TypeScript in client README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Restore logo image in client and react READMEs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add logo and badges to react-native README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update Twitter/X handle from elevenlabsio to ElevenLabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Pass agentId to ConversationProvider, callbacks to startSession

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use realistic agent ID placeholder in README examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add "replace with your agent's ID" comment to README examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replace agents-platform with eleven-agents in all doc URLs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…, react, react-native

- Change pre.json tag from "next" to "rc" so versions are x.y.z-rc.N
- Add publishConfig.tag "next" to client, react, react-native packages
- Ignore non-scoped packages during pre-release period

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Version Packages (rc)

* Trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kræn Hansen <kraen@elevenlabs.io>
)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…venlabs#613)

* support persisting mute state across conversation sessions

* Apply suggestion from @kraenhansen

* get rid of unneeded onConnect

* Add tests for controlled mute edge cases

- Verify disconnect does not reset controlled isMuted
- Verify onMutedChange fires in uncontrolled mode
- Verify setMuted throws pre-session in controlled mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add changeset for controlled mute state feature

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix controlled setMuted eagerly mutating SDK state

In controlled mode, setMuted no longer calls conversation.setMicMuted()
directly. Instead, it only notifies the parent via onMutedChange and
lets the useEffect sync the SDK when the controlled prop changes. This
prevents state desync when the parent rejects the change and eliminates
double setMicMuted calls in the normal flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kræn Hansen <mail@kraenhansen.dk>
Co-authored-by: Kræn Hansen <kraen@elevenlabs.io>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Infer default connection type from conversation mode

Voice conversations now default to "webrtc" and text-only conversations
default to "websocket" when connectionType is not explicitly specified.

Closes elevenlabs#579

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix test to exercise textOnly inference code path

Use agentId instead of signedUrl so the test actually reaches
the textOnly inference logic in determineConnectionType().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Throw when signedUrl is used with connectionType webrtc

signedUrl only supports websocket connections, so reject the
invalid combination early with a clear error message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use vi.stubGlobal for fetch mock to ensure test isolation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread packages/react-native/src/index.react-native.ts
Comment thread packages/react-native/src/index.react-native.ts
Comment thread packages/react-native/src/ReactNativeOutputForWebSocket.ts Outdated

@kraenhansen kraenhansen left a comment

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.

Thanks a lot for taking a look at this! On a general note, I think we should rely on Expo CNG and add the android and ios directories to the .gitignore of the repo.

I've added a few questions / comments below.

audio: true,
});
} catch (_e) {
// Non-fatal: platform strategy will handle mic access

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.

Not a huge fan of silencing errors in general 🤔 At the very least we should warn here. It seems we could easily be moved up above the first try { to prevent a try in a try.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense!

playbackEventTarget?.addListener(this.handlePlaybackEvent);

if (wakeLock) {
if (wakeLock && typeof document !== "undefined") {

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.

I'd like to avoid runtime checks like these in these common files, instead this should be a part of the client/src/platform/ abstraction and injected by the platform-specific SDK.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually now that I'm looking at those checks, they are unneeded bcs wakeLock is null in RN, I'll revert them. Thanks for pointing out!

await super.handleEndSession();

if (this.visibilityChangeHandler) {
if (this.visibilityChangeHandler && typeof document !== "undefined") {

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.

Same as above.

Comment on lines +40 to +46
const output = await ReactNativeOutputForWebSocket.create(
connection.outputFormat
);
const input = await ReactNativeInputForWebSocket.create(
connection.inputFormat,
output.audioContext
);

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.

I was expecting that we'd be able to reuse the LiveKit AudioSession utility / MediaRecorder shim to start and stop sound capturing even for WebSocket - was that something you experimented with initially or did you go straight for a "react-native-audio-api" based solution?

I see your PR description says the LiveKit MediaRecorder shim was rejected, but have you actually tried experimented with an approach based off that? I'd like for us to avoid taking on another native dependency, if at all possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I tried but it appeared that they are designed to work with WebRTC peer connection which is undefined in case of WebSocket and audio processing didn't work and I failed there :( Then I tried expo-av but the sound was breaking up. The last 2 options were writing native code for iOS and Android or use this react-native-audio-api lib. I decided to go with the last to not introduce Swift/Kotlin code there.

@brojd

brojd commented Mar 23, 2026

Copy link
Copy Markdown
Contributor Author

On a general note, I think we should rely on Expo CNG and add the android and ios directories to the .gitignore of the repo

Agree! I was hesitant bcs android/ios dirs were already committed. Let's remove them and add to .gitignore 👍

Comment thread packages/react-native/src/index.react-native.ts Outdated
@kraenhansen

kraenhansen commented Mar 23, 2026

Copy link
Copy Markdown
Member

Once #624 merge I'll update next and you can rebase 👍 I will likely want to try the LiveKit MediaRecorder myself first before merging this PR though.

Comment thread packages/react-native/src/ReactNativeInputForWebSocket.ts
@kraenhansen kraenhansen self-assigned this Mar 24, 2026
});
} catch (e) {
console.warn("Failed to acquire preliminary media stream:", e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Silenced getUserMedia changes web error flow

Medium Severity

Wrapping the preliminary getUserMedia in a separate try/catch that only logs a warning changes the error flow on web. Previously, a denied mic permission would fail immediately before establishing a server connection. Now, the error is silently swallowed, a server connection is established (consuming server resources), and the session only fails later when MediaDeviceInput.create() tries getUserMedia again — producing a less clear error path and wasting a connection slot.

Fix in Cursor Fix in Web

Comment thread packages/react-native/src/ReactNativeOutputForWebSocket.ts Outdated
Comment thread packages/react-native/src/audio.ts
Comment thread packages/react-native/src/index.react-native.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

);
const detachInput = attachInputToConnection(input, connection);
const detachOutput = attachConnectionToOutput(connection, output);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resource leak on partial failure during WebSocket setup

Medium Severity

In reactNativeSessionSetup, the WebSocket path creates connection, input, and output sequentially with no try/catch around the group. If ReactNativeOutputForWebSocket.create throws after input was successfully created, neither input.close() nor connection.close() is called, and the audio session remains active (AudioManager.setAudioSessionActivity is never set to false). Similarly, if input creation fails, the connection is leaked. The web version avoids this partially via Promise.all, but here sequential creation requires explicit cleanup of earlier resources on later failures.

Fix in Cursor Fix in Web

@kraenhansen kraenhansen added @elevenlabs/react-native Issues and RPs related to the `@elevenlabs/react-native` package. @elevenlabs/client Issues and PRs related to the `@elevenlabs/client` package. labels May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

@elevenlabs/client Issues and PRs related to the `@elevenlabs/client` package. @elevenlabs/react-native Issues and RPs related to the `@elevenlabs/react-native` package.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants