Skip to content

[@elevenlabs/react] ConversationProvider state broken on iOS Safari 18+ — tools don't fire, status stuck at disconnected #663

Description

@amarcher

Summary

ConversationProvider from @elevenlabs/react is broken on iOS Safari 18.7. When using useConversation() + useConversationClientTool() inside a ConversationProvider, the session connects at the WebSocket level (the onConnect callback fires, audio streams in both directions), but React state never updates:

  • status reports disconnected within 1ms of briefly showing connected
  • isSpeaking never updates
  • Client tools registered via useConversationClientTool() never fire — the SDK logs "Client tool not defined" errors for every tool call

The same agent, same device, same browser works perfectly when calling Conversation.startSession() from @elevenlabs/client directly, bypassing the React provider entirely.

Environment

Component Version
Device iPhone 16 Pro (iOS 18.7)
Browser Safari 26.4 / WebKit
@elevenlabs/react 1.0.3
@elevenlabs/client 1.1.2
React 19.x
Vite 8.x

Desktop browsers (Chrome, Firefox, Safari on macOS) work correctly with ConversationProvider.

Steps to reproduce

// main.tsx
import { ConversationProvider } from '@elevenlabs/react';

createRoot(document.getElementById('root')!).render(
  <ConversationProvider>
    <App />
  </ConversationProvider>
);

// App.tsx
import { useConversation, useConversationClientTool } from '@elevenlabs/react';

function App() {
  const { status, isSpeaking, startSession, endSession } = useConversation();

  useConversationClientTool('navigate_to_planet', (params) => {
    console.log('Tool called:', params); // Never fires on iOS
    return 'OK';
  });

  return (
    <button onClick={() => {
      if (status === 'connected') endSession();
      else startSession({ agentId: 'your-agent-id' });
    }}>
      {status} {isSpeaking ? '(speaking)' : ''}
    </button>
  );
}
  1. Deploy to a public URL (localhost won't have mic permissions on iOS)
  2. Open on iPhone running iOS 18.7 in Safari
  3. Tap the button to start a session
  4. Observe: button shows "disconnected" even though the agent is audibly speaking
  5. Ask the agent to use a tool — observe "Client tool not defined" in the console

Expected behavior

  1. status transitions: disconnectedconnectingconnected
  2. isSpeaking toggles as the agent speaks
  3. Client tools fire when the agent invokes them

Actual behavior (iOS Safari 18.7)

  1. startSession() is called
  2. status briefly flashes connected, then immediately reverts to disconnected (~1ms)
  3. The WebSocket connection IS alive — the agent speaks, audio plays (after the first message), transcripts appear in onMessage
  4. isSpeaking never changes from false
  5. When the agent tries to call a client tool, the SDK logs: "Client tool not defined: navigate_to_planet" — the tool registrations from useConversationClientTool() are not reaching the live session
  6. The session is effectively headless: audio works, but all React state and tool dispatch is broken

Root cause analysis

The provider's internal state management breaks on iOS Safari. Based on extensive debugging:

  1. ConversationProvider wraps Conversation.startSession() in a .then(success, failure) chain that manages React context state
  2. On iOS Safari, either:
    • The promise rejects silently (the failure handler in ConversationProvider.js:85 runs but only sets lockRef.current = null without logging), or
    • The provider's internal state machine enters an inconsistent state where the WebSocket session is alive but the React context layer believes it's disconnected
  3. Because the context reports disconnected, the tool dispatch layer (which is gated on connection status) never forwards tool calls to the registered handlers
  4. The useConversationClientTool() registrations are stored in React context state that is stale/disconnected from the live Conversation instance

Evidence

When using ConversationProvider on iOS Safari:

onConnect fired (+3982ms)                      ← SDK callback fires
onStatusChange: {status: "disconnected"}       ← React state immediately reverts (same ms)
isSpeaking: false (never changes)
"Client tool not defined: navigate_to_planet"  ← tools not registered on live session

When bypassing the provider via Conversation.startSession() from @elevenlabs/client on the SAME device:

onConnect fired (+3982ms)
onStatusChange: {status: "connected"}          ← stays connected
onModeChange: {mode: "speaking"}               ← updates correctly
onMessage: {source: "user", message: "Go to Mars."}
navigate_to_planet called: {name: "Mars"}      ← tool works

This confirms the bug is in ConversationProvider's state management, not in the underlying client SDK or iOS Safari's WebSocket support.

Workaround

Bypass ConversationProvider and use Conversation from @elevenlabs/client directly:

import { Conversation } from '@elevenlabs/client';

const convRef = useRef<Conversation | null>(null);
const [status, setStatus] = useState('disconnected');

const start = async () => {
  setStatus('connecting');
  const conv = await Conversation.startSession({
    agentId: 'your-agent-id',
    clientTools: {
      navigate_to_planet: (params) => {
        // Works reliably on iOS
        return 'OK';
      },
    },
    onConnect: () => setStatus('connected'),
    onDisconnect: () => { setStatus('disconnected'); convRef.current = null; },
    onModeChange: (m) => setIsSpeaking(m.mode === 'speaking'),
  });
  convRef.current = conv;
};

Related issues

Additional iOS issue (separate)

There is a second, unrelated issue on iOS Safari: the first agent message audio is inaudible. Subsequent messages play normally.

  • AudioContext.state is running
  • The SDK's hidden <audio> element has currentTime advancing past 15+ seconds
  • paused is false, volume is 1, readyState is 4
  • No audible output from the speaker

This appears to be related to MediaDeviceOutput.create() in output.ts using audioElement.autoplay = true with srcObject = MediaStream but never calling audioElement.play() explicitly. On iOS Safari, autoplay with srcObject does not produce audible output without an explicit play() call in the user gesture chain. Additionally, getUserMedia() switches iOS to AVAudioSessionCategoryPlayAndRecord which routes initial audio output to the earpiece at near-zero volume.

Metadata

Metadata

Assignees

Labels

@elevenlabs/reactIssues and PRs related to the `@elevenlabs/react` package.bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions