Skip to content

React

import { useState } from 'react'
import { useAgentInput } from 'alkove/react'
function Agent({ ws }: { ws: WebSocket }) {
const [on, setOn] = useState(false)
const { status, error, resume } = useAgentInput({
enabled: on,
near: true,
on: {
utterance: ({ pcm }) => ws.send(pcm),
turnend: () => ws.send(JSON.stringify({ type: 'reply' })),
},
})
if (error) return <p>{error.message}</p>
if (status === 'blocked') return <button onClick={resume}>Turn the microphone on</button>
return <button onClick={() => setOn(!on)}>{on ? 'Stop' : 'Talk'}</button>
}

The hooks come with the alkove package, under alkove/react, for React 18 and later. Each one wraps a function of the package: useAgentInput createAgentInput, useCallInput createCallInput, useWakeListener createWakeListener. They take the same options, plus two of their own:

  • enabled: false opens nothing, no microphone and no model; switching it to false closes the input. true by default.
  • on: the events, by name (speechend, turnend, utterance, wake…). The latest functions are always called: an arrow written inline never reopens the input.
status 'off' before the input opens or after it closed, then the input’s own: 'starting', 'running', 'blocked', 'interrupted'
mode 'focus', 'focus+print', 'spot', 'spot+print' or 'raw'; null while closed
error what kept the input from opening, an AlkoveError such as mic.denied or page.insecure; null otherwise
track the output track, for RTCPeerConnection.addTrack or LiveKit; follows trackchange, which a bound input (bind()) does not send: it swaps the sender’s track itself
input the input itself once open, for listen(), hold(), record(), bind()…
resume to call in a click when status is 'blocked': the browser kept the audio suspended until a user gesture

strength and voiceprint apply to the running input. Any other option closes the input and opens a new one: deviceId to switch microphones, near, turn, the words of a wake listener. Options are compared by content, so an object written inline in the component reopens nothing.

React’s strict mode mounts each component twice in development: the first opening is cancelled. Inputs open one after the other, across every hook of the page, a new one once the previous has opened or failed: two inputs never hold the microphone at once, even when a component is mounted again while its input still opens.

alkove/react is marked 'use client': import it from a client component. Nothing touches the browser until a hook runs.

import { useEffect } from 'react'
import { useCallInput } from 'alkove/react'
function Call({ pc }: { pc: RTCPeerConnection }) {
const { input } = useCallInput({ near: true })
useEffect(() => {
if (!input) return
const sender = pc.addTrack(input.track)
input.bind(sender)
return () => pc.removeTrack(sender)
}, [input, pc])
return null
}

An input opened again, after an option changed, comes as a new input: the effect moves the call to its track. bind() lets the input swap the sender’s track itself when the audio clock stalls; see calls over WebRTC.

import { Spot, type Word } from 'alkove'
import { useWakeListener } from 'alkove/react'
import { useEffect, useState } from 'react'
function Wake({ onWake }: { onWake: () => void }) {
const [word, setWord] = useState<Word | null>(null)
useEffect(() => {
Spot.compile('Hey Nova', { lang: 'en' }).then(setWord)
}, [])
useWakeListener({ enabled: !!word, words: word ? [word] : [], on: { wake: onWake } })
return null
}

words takes compiled words, as createWakeListener does: a Word from Spot.compile, or its JSON saved earlier.

Two apps in the repository, examples/next-agent (Next.js, useAgentInput: each sentence back as a clip, the ends of turns) and examples/vite-wake (Vite, useWakeListener on a word typed in the page).