Skip to main content
The useThunderPhone hook gives you complete control over the user interface while ThunderPhone manages the voice session, audio routing, and connection state. Use it when the pre-built widget does not fit your design or you need custom call flows.

Installation

npm install @thunderphone/widget
The headless hook does not require importing @thunderphone/widget/style.css since you are providing your own UI. However, you must still install the same @thunderphone/widget package.

Basic Usage

import { useThunderPhone } from '@thunderphone/widget'

function CustomCallButton() {
  const phone = useThunderPhone({
    apiKey: 'pk_live_your_publishable_key',
    agentId: 123,
  })

  const handleClick = () => {
    if (phone.state === 'connected') {
      phone.disconnect()
    } else {
      phone.connect()
    }
  }

  return (
    <>
      <button onClick={handleClick} disabled={phone.state === 'connecting'}>
        {phone.state === 'connecting'
          ? 'Connecting...'
          : phone.state === 'connected'
            ? 'End call'
            : 'Start call'}
      </button>
      {phone.audio}
    </>
  )
}
You must render phone.audio somewhere in your component tree. It is an invisible React element that manages the underlying audio connection. If you omit it, no audio will play and the session will not work.

Options

Pass these options to useThunderPhone via UseThunderPhoneOptions:
OptionTypeRequiredDescription
apiKeystringYesPublishable API key (pk_live_...)
agentIdnumberYesID of the agent to connect to
apiBasestringNoAPI base URL override (defaults to https://api.thunderphone.com/v1)
onConnect() => voidNoCalled when the voice session connects
onDisconnect() => voidNoCalled when the session ends
onError(error) => voidNoCalled on errors. Error has error (code) and message fields
ringtoneboolean | stringNoPlay a ringtone while connecting. true for the default ringtone, or a URL string for custom audio. Disabled by default

Return Value

The hook returns a UseThunderPhoneReturn object:
PropertyTypeDescription
state'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'Current connection state
connect() => voidStart a voice session
disconnect() => voidEnd the current session
toggleMute() => voidToggle microphone mute on/off
isMutedbooleanWhether the microphone is currently muted
errorstring | undefinedError message when state is 'error'
agentNamestring | undefinedDisplay name of the connected agent
audioReactNodeInvisible element that handles the audio connection — must be rendered

State Machine

The state property follows this lifecycle:
idle --> connecting --> connected --> disconnected --> idle
                  \
                   --> error --> idle (after timeout)
StateDescription
idleNo active session. Ready to call connect().
connectingSession is being established. Disable the call button during this state.
connectedVoice session is active. The user is talking to the agent.
disconnectedSession has ended cleanly. Transitions back to idle.
errorSomething went wrong. Check phone.error for the message. Automatically returns to idle after a timeout.

Examples

With Mute Control

import { useThunderPhone } from '@thunderphone/widget'

function CallWithMute() {
  const phone = useThunderPhone({
    apiKey: 'pk_live_your_publishable_key',
    agentId: 123,
  })

  return (
    <div>
      {phone.state === 'connected' && (
        <div>
          <p>Talking to {phone.agentName ?? 'Agent'}</p>
          <button onClick={phone.toggleMute}>
            {phone.isMuted ? 'Unmute' : 'Mute'}
          </button>
          <button onClick={phone.disconnect}>End call</button>
        </div>
      )}

      {phone.state !== 'connected' && (
        <button
          onClick={phone.connect}
          disabled={phone.state === 'connecting'}
        >
          {phone.state === 'connecting' ? 'Connecting...' : 'Call support'}
        </button>
      )}

      {phone.state === 'error' && (
        <p style={{ color: 'red' }}>{phone.error}</p>
      )}

      {phone.audio}
    </div>
  )
}

With Ringtone

Play a ringing sound while connecting to simulate a phone call:
import { useThunderPhone } from '@thunderphone/widget'

function PhoneCallButton() {
  const phone = useThunderPhone({
    apiKey: 'pk_live_your_publishable_key',
    agentId: 123,
    ringtone: true, // or a custom URL: 'https://example.com/ringtone.mp3'
  })

  return (
    <>
      <button
        onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
        disabled={phone.state === 'connecting'}
      >
        {phone.state === 'connecting'
          ? 'Ringing...'
          : phone.state === 'connected'
            ? 'Hang up'
            : 'Call'}
      </button>
      {phone.audio}
    </>
  )
}
The ringtone loops during the connecting state and fades out when the agent connects. Pass true for the built-in default ringtone, or a URL string to use your own audio file.

With Event Callbacks

import { useThunderPhone } from '@thunderphone/widget'

function TrackedCallButton() {
  const phone = useThunderPhone({
    apiKey: 'pk_live_your_publishable_key',
    agentId: 123,
    onConnect: () => {
      analytics.track('call_started')
    },
    onDisconnect: () => {
      analytics.track('call_ended')
    },
    onError: (error) => {
      analytics.track('call_error', { code: error.error, message: error.message })
    },
  })

  return (
    <>
      <button
        onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
        disabled={phone.state === 'connecting'}
      >
        {phone.state === 'connected' ? 'Hang up' : 'Talk to AI'}
      </button>
      {phone.audio}
    </>
  )
}

Full Custom UI

import { useThunderPhone } from '@thunderphone/widget'

function FullCustomUI() {
  const phone = useThunderPhone({
    apiKey: 'pk_live_your_publishable_key',
    agentId: 123,
  })

  return (
    <div className="call-panel">
      <div className="call-status">
        {phone.state === 'idle' && <span>Ready</span>}
        {phone.state === 'connecting' && <span className="pulse">Connecting...</span>}
        {phone.state === 'connected' && (
          <span>On call with {phone.agentName}</span>
        )}
        {phone.state === 'error' && <span className="error">{phone.error}</span>}
      </div>

      <div className="call-controls">
        {phone.state === 'connected' ? (
          <>
            <button className="mute-btn" onClick={phone.toggleMute}>
              {phone.isMuted ? 'Unmute' : 'Mute'}
            </button>
            <button className="end-btn" onClick={phone.disconnect}>
              End
            </button>
          </>
        ) : (
          <button
            className="start-btn"
            onClick={phone.connect}
            disabled={phone.state === 'connecting'}
          >
            Start call
          </button>
        )}
      </div>

      {/* Required -- handles audio under the hood */}
      {phone.audio}
    </div>
  )
}

Tips

The phone.audio element is invisible but required. Place it anywhere in your JSX — it renders no visible DOM but manages the WebRTC audio connection internally.
The connecting state can last 1-3 seconds. Disable the call button during this state to prevent duplicate connection attempts.
When the state is error, display phone.error to the user. The state will automatically return to idle after a few seconds, so the user can try again.
The onConnect, onDisconnect, and onError callbacks are ideal for analytics, logging, or triggering other application logic without polling the state.