Skip to main content

Voice Input

Voice is opt-in on both SDKs. The SDKs define the speech contract (SpeechHost) but ship no speech engine of their own:

  • Web (@appilots/web-sdk) has one built-in engine over the browser's Web Speech API, and it also accepts your own SpeechHost.
  • React Native (@appilots/sdk) has no built-in engine. Every usable engine is a native module, so you write a small adapter (see the recipe below) and pass it in.

Without an engine there is no mic button. The SDK never renders a mic button that does nothing.

Web​

<AppilotsChat locale="pt-BR" speech="browser" />
// or
<AppilotsChatFab locale="pt-BR" speech="browser" />

speech="browser" uses SpeechRecognition / webkitSpeechRecognition. Where the browser has neither (Firefox, many embedded WebViews), the mic button is hidden. You can check support yourself with isWebSpeechSupported().

How it behaves:

  • Toggle, not hold-to-talk. Press once to start and once to stop, with a mouse, touch, Enter/Space or a switch device. Nobody has to hold a button down.
  • The draft is never sent automatically. Recognised text goes into the composer as the user speaks, and appears after any text already typed there. When the mic stops, the text is editable. Sending is still a separate action.
  • Escape (in the composer or on the mic) discards the dictation and restores the draft as it was before the mic went on.
  • Accessibility. The mic is a <button aria-pressed> with a translated name ("Ditar por voz" / "Dictate by voice"). A role="status" region announces "Ouvindo…" and "Parou de ouvir…", and it also shows that text on screen, so the state is not conveyed by colour alone. The composer is read-only while the mic is on.
  • Locale. Recognition uses the chat's locale (pt-BR or en).
  • Silence. Chrome closes a capture after a few seconds of silence. The engine reopens it, so a user who needs time to speak is not cut off. After about five silent windows in a row it gives up and the button returns to off.

Bring your own engine (web)​

Pass any object that implements SpeechHost. Use this for a vendor engine, an on-device model, or a server you control:

import type { SpeechHost } from '@appilots/web-sdk';

const host: SpeechHost = {
async start({ locale }) {
/* open the mic; resolve once capturing */
},
async stop() {
/* close the mic; resolve with the final text */ return text;
},
async cancel() {
/* close the mic; discard */
},
onPartial(listener) {
/* interim text */ return () => {};
},
onEnd(listener) {
/* engine stopped by itself; listener(error?) */ return () => {};
},
};

<AppilotsChat speech={host} />;

Create the host once, for example at module scope or in useMemo. The chat reacts when the speech prop changes identity.

The SpeechHost contract​

MemberRequiredMeaning
start({ locale })yesStart listening. Resolve once capture is actually running. Handle the permission prompt here, and reject if it is refused.
stop()yesStop listening and resolve with the final transcript. '' is a normal result, not an error.
cancel()yesStop listening and discard the result. Must not throw.
onPartial(listener)noInterim text while the user speaks. Returns a disposer.
onEnd(listener)noThe engine stopped on its own (timeout, lost permission, network), with error set when it failed. Returns a disposer. Without this, the button keeps showing "listening" after the engine has stopped.

locale is a BCP-47 tag taken from the chat's locale.

React Native: adapter recipe​

The React Native chat uses a hold-to-talk mic: pressing starts capture, and releasing stops it and sends the transcript. Enable it with experience={{ input: ['text', 'voice'] }} and a speech adapter.

The recipe below uses expo-speech-recognition. Install it in your app, not in the SDK:

npx expo install expo-speech-recognition

Permissions. The config plugin writes these into the native projects on the next prebuild:

{
"expo": {
"plugins": [
[
"expo-speech-recognition",
{
"microphonePermission": "Permitir o uso do microfone para ditar mensagens ao assistente.",
"speechRecognitionPermission": "Permitir o reconhecimento de voz para transformar sua fala em texto."
}
]
]
}
}
  • iOS: NSMicrophoneUsageDescription and NSSpeechRecognitionUsageDescription.
  • Android: android.permission.RECORD_AUDIO.

A bare React Native app sets these in Info.plist and AndroidManifest.xml by hand.

speechHost.ts
import { ExpoSpeechRecognitionModule } from 'expo-speech-recognition';
import type { SpeechHost } from '@appilots/sdk';

type Sub = { remove(): void };

/**
* @param onDevice keep audio on the device (iOS 13+, Android 13+ with an
* on-device model installed). Fails to start where unsupported.
*/
export function createExpoSpeechHost({ onDevice = false } = {}): SpeechHost {
let committed = '';
let current = '';
let subs: Sub[] = [];
let stopping: ((text: string) => void) | null = null;
const partials = new Set<(t: string) => void>();
const ends = new Set<(e?: unknown) => void>();
const text = () => `${committed} ${current}`.trim();
const unsubscribe = () => {
subs.forEach((s) => s.remove());
subs = [];
};

return {
async start({ locale }) {
const { granted } = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
if (!granted) throw new Error('speech permission denied');
unsubscribe();
committed = current = '';
await new Promise<void>((resolve, reject) => {
let started = false;
subs = [
ExpoSpeechRecognitionModule.addListener('start', () => {
started = true;
resolve();
}),
ExpoSpeechRecognitionModule.addListener('result', (e) => {
const t = e.results[0]?.transcript ?? '';
if (e.isFinal) {
committed = `${committed} ${t}`.trim();
current = '';
} else current = t;
partials.forEach((l) => l(text()));
}),
ExpoSpeechRecognitionModule.addListener('error', (e) => {
if (!started) reject(new Error(e.error));
else if (!stopping) ends.forEach((l) => l(new Error(e.error)));
}),
ExpoSpeechRecognitionModule.addListener('end', () => {
unsubscribe();
if (stopping) {
stopping(text());
stopping = null;
} else if (started) ends.forEach((l) => l());
}),
];
ExpoSpeechRecognitionModule.start({
lang: locale,
interimResults: true,
continuous: true,
requiresOnDeviceRecognition: onDevice,
});
});
},
stop() {
return new Promise((resolve) => {
stopping = resolve;
ExpoSpeechRecognitionModule.stop();
});
},
async cancel() {
stopping = null;
unsubscribe();
ExpoSpeechRecognitionModule.abort();
},
onPartial(listener) {
partials.add(listener);
return () => partials.delete(listener);
},
onEnd(listener) {
ends.add(listener);
return () => ends.delete(listener);
},
};
}
const speech = createExpoSpeechHost(); // once, at module scope

<AppilotsChat experience={{ input: ['text', 'voice'] }} speech={speech} />;

Check the event names and options against the version of expo-speech-recognition you install. The recipe is written against its documented start / result / error / end events.

@react-native-voice/voice fits the same contract. Map start to Voice.start(locale) and resolve on onSpeechStart, map stop to Voice.stop() and resolve on onSpeechResults, map cancel to Voice.cancel(), map onPartial to onSpeechPartialResults, and map onEnd to onSpeechError and to an unrequested onSpeechEnd. Request RECORD_AUDIO yourself on Android with PermissionsAndroid before Voice.start. Call Voice.destroy() when the adapter is disposed.

Privacy: where the audio goes​

Appilots never receives audio. Only the text the user chooses to send reaches the Appilots API, the same as a typed message. The recognition engine is chosen and configured by you, and the audio goes wherever that engine sends it:

EngineWhere recognition runs
Chrome / Edge, speech="browser"Off the device. The browser streams the audio to the vendor's speech service (Google / Microsoft).
Safari, speech="browser"Apple's speech recognition. It may run on Apple's servers, depending on OS version and language.
FirefoxNot supported, so the mic is hidden.
iOS SFSpeechRecognizer (via the adapters above)Apple's servers, unless requiresOnDeviceRecognition (onDevice: true) is set and the language supports it.
Android SpeechRecognizerUsually Google's service, which may run on the device or on servers depending on the device and model. Requesting on-device recognition fails to start where it is unavailable.

What this means for LGPD, and especially for children and adolescents:

  • A voice recording is personal data, and it can identify a person. If the engine is a cloud service, that service is an additional data processor (operador), and sending the audio to it is usually an international transfer (LGPD art. 33). Your privacy notice must say so.
  • For users under 18, LGPD art. 14 requires processing in the child's best interest. For children under 12 it requires specific consent from a parent or guardian. Make voice an explicit, per-user choice, not a default, and explain it in language the user can understand.
  • speech="browser" on Chrome has no settings you can change: no retention controls, no on-device switch, and no data-processing agreement that Appilots knows of. If your legal basis needs any of those, pass your own SpeechHost backed by an engine you have a contract with, or one that runs on the device. Your DPO should confirm this before a pilot with minors.
  • Recognition happens only while the mic button is on. The mic turns off when the user stops it, cancels it, sends the message or closes the chat, or after a long silence.