# How to Use Voice Input in Retool Applications

- Tool: Retool
- Difficulty: Beginner
- Time required: 20-25 min
- Compatibility: Retool Cloud and Self-hosted (Chrome and Edge only — Web Speech API not supported in Firefox or Safari)
- Last updated: March 2026

## TL;DR

Use the browser's built-in Web Speech API inside a JS Query to capture voice input. Create a SpeechRecognition instance, set onresult to update a Temporary State with the transcript, and call recognition.start() on button click. Bind the Text Input's default value to the state variable. Works in Chrome and Edge; Safari/Firefox have limited support.

## Adding Voice-to-Text Input to Retool Apps

The Web Speech API is a browser-native API that provides speech recognition without any third-party service. Because Retool apps run in the browser, JS Queries have full access to browser APIs including SpeechRecognition. This means you can add voice input to any Retool form without external API keys or services.

The pattern works as follows: a button triggers a JS Query that creates a SpeechRecognition instance. As the user speaks, the API fires onresult events with live transcripts. The JS Query writes the transcript into a Temporary State variable. A Text Input component reads from that state variable, allowing the user to review and edit before submitting.

Chrome and Edge have full support. Firefox does not implement the Web Speech API. Safari has partial support behind a flag. For cross-browser support, consider a Custom Component wrapping the AssemblyAI or Whisper API instead.

## Before you start

- A Retool app with at least one Text Input that should accept voice input
- Chrome or Edge browser (Web Speech API not available in Firefox/Safari)
- Microphone access on the device running the Retool app

## Step-by-step guide

### 1. Create a Temporary State to hold the transcript

Voice transcripts are delivered asynchronously via browser events, so you need a Temporary State variable to bridge the async callback and the Retool component. In the left panel, click + State or go to the State tab in the Debug Panel. Create a new Temporary State named 'voiceTranscript' with a default value of an empty string. You will write the recognized speech into this state from the JS Query, and the Text Input component will read from it.

**Expected result:** Temporary State 'voiceTranscript' created with default value ''.

### 2. Add a second Temporary State for recording status

You also need to track whether recording is currently active so the UI can show a visual indicator. Create a second Temporary State named 'isRecording' with a default value of false. You will use this to change the button label and color between 'Start Recording' and 'Stop Recording' states, giving the user clear feedback.

**Expected result:** Temporary State 'isRecording' created with default value false.

### 3. Create the startVoiceInput JS Query

Create a new JS Query named 'startVoiceInput'. This query initializes the SpeechRecognition API and wires up event handlers to update the Temporary States. The key callbacks are: onstart (set isRecording to true), onresult (update voiceTranscript with the latest transcript), onerror (handle permission denied and other errors), and onend (set isRecording back to false).

```
// JS Query: startVoiceInput
// Check browser support
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
  utils.showNotification({
    title: 'Browser not supported',
    description: 'Voice input requires Chrome or Edge. Your current browser does not support the Web Speech API.',
    notificationType: 'error',
  });
  return;
}

// Create SpeechRecognition instance
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();

// Configuration
recognition.lang = 'en-US'; // Language — change to 'es-ES', 'fr-FR', etc. as needed
recognition.interimResults = true; // Show in-progress text while still speaking
recognition.maxAlternatives = 1; // Return only the top recognition result
recognition.continuous = false; // Stop automatically after a pause in speech

// Store recognition on window so stopVoiceInput query can access it
window._retoolSpeechRecognition = recognition;

recognition.onstart = () => {
  isRecording.setValue(true);
  utils.showNotification({
    title: 'Listening...',
    description: 'Speak now. Recording will stop automatically after a pause.',
    notificationType: 'info',
    duration: 3,
  });
};

recognition.onresult = (event) => {
  let interimTranscript = '';
  let finalTranscript = '';

  // Iterate over all results
  for (let i = event.resultIndex; i < event.results.length; i++) {
    const transcript = event.results[i][0].transcript;
    if (event.results[i].isFinal) {
      finalTranscript += transcript;
    } else {
      interimTranscript += transcript;
    }
  }

  // Update state with current transcript (final preferred, interim as fallback)
  const currentText = voiceTranscript.value || '';
  const newText = currentText + (finalTranscript || interimTranscript);
  voiceTranscript.setValue(newText);
};

recognition.onerror = (event) => {
  isRecording.setValue(false);

  const errorMessages = {
    'not-allowed': 'Microphone access denied. Click the camera/mic icon in your browser address bar to allow microphone access.',
    'no-speech': 'No speech detected. Make sure your microphone is working and try again.',
    'network': 'Network error during speech recognition. Check your internet connection.',
    'aborted': 'Recording was cancelled.',
  };

  const message = errorMessages[event.error] || `Speech recognition error: ${event.error}`;
  utils.showNotification({
    title: 'Voice input error',
    description: message,
    notificationType: 'error',
  });
};

recognition.onend = () => {
  isRecording.setValue(false);
};

// Start recording
recognition.start();
```

**Expected result:** JS Query created. Running it starts the microphone and begins updating voiceTranscript with recognized speech.

### 4. Create the stopVoiceInput JS Query

Create a second JS Query named 'stopVoiceInput'. This query retrieves the SpeechRecognition instance stored on window in the startVoiceInput query and calls recognition.stop(). The onend callback in startVoiceInput will automatically set isRecording to false when the recognition stops.

```
// JS Query: stopVoiceInput
// Stops the active SpeechRecognition session
const recognition = window._retoolSpeechRecognition;

if (recognition) {
  recognition.stop();
  window._retoolSpeechRecognition = null;
} else {
  utils.showNotification({
    title: 'Not recording',
    description: 'No active recording session to stop.',
    notificationType: 'warning',
  });
}
```

**Expected result:** stopVoiceInput query stops the active recording session when triggered.

### 5. Configure the microphone button

Add a Button component to your app. Set the button's label using a conditional expression: {{ isRecording.value ? 'Stop Recording' : 'Start Recording' }}. Set the button color to red when recording: use the button's Style tab to set background color using {{ isRecording.value ? '#ef4444' : '#6366f1' }}. Add an event handler for the onClick event: when isRecording is false, trigger startVoiceInput; when isRecording is true, trigger stopVoiceInput. Use two separate event handlers with 'Only run when' conditions.

```
// Button label expression:
{{ isRecording.value ? '🎙 Stop Recording' : '🎤 Start Recording' }}

// Event Handler 1: Start recording
// Trigger: startVoiceInput
// Only run when: {{ !isRecording.value }}

// Event Handler 2: Stop recording
// Trigger: stopVoiceInput
// Only run when: {{ isRecording.value }}
```

**Expected result:** Button shows 'Start Recording' by default. Clicking starts recognition. Button turns red and shows 'Stop Recording'. Clicking again stops recording.

### 6. Bind the Text Input to the voiceTranscript state

Select your Text Input component. In the Inspector, set the Default value to {{ voiceTranscript.value }}. This makes the text input reflect whatever the voice recognition has transcribed. Because it's a Text Input (not read-only), users can also edit the transcribed text before submitting the form — this is important since speech recognition is rarely 100% accurate.

**Expected result:** Text Input shows the live transcription as the user speaks and remains editable for corrections.

### 7. Add a language selector for multi-language support

If your app needs to support multiple languages, add a Select component that lets users choose their language before recording. Store the selection in a Temporary State named 'recognitionLang' with default value 'en-US'. In the startVoiceInput query, change the hardcoded 'en-US' to {{ recognitionLang.value }}. Common language codes: 'en-US' (English US), 'en-GB' (English UK), 'es-ES' (Spanish), 'fr-FR' (French), 'de-DE' (German), 'ja-JP' (Japanese), 'zh-CN' (Mandarin).

```
// In startVoiceInput, replace the lang line with:
recognition.lang = recognitionLang.value || 'en-US';

// Select component options:
// [
//   { label: 'English (US)', value: 'en-US' },
//   { label: 'English (UK)', value: 'en-GB' },
//   { label: 'Spanish', value: 'es-ES' },
//   { label: 'French', value: 'fr-FR' },
//   { label: 'German', value: 'de-DE' },
//   { label: 'Japanese', value: 'ja-JP' }
// ]
```

**Expected result:** Language selector controls which language the speech recognizer targets.

### 8. Handle continuous recording mode for long inputs

By default, recognition.continuous = false stops automatically after a pause. For use cases like voice notes or long dictation, set continuous to true. In continuous mode, the recognition does not stop after a pause — you must call recognition.stop() explicitly. Update the startVoiceInput query to use continuous = true for long-form input, and instruct users to click 'Stop Recording' when finished.

```
// For long-form dictation, change:
recognition.continuous = true; // Don't stop on pause
recognition.interimResults = true;

// In onresult, accumulate results across multiple speech segments:
recognition.onresult = (event) => {
  let fullTranscript = '';
  for (let i = 0; i < event.results.length; i++) {
    if (event.results[i].isFinal) {
      fullTranscript += event.results[i][0].transcript + ' ';
    }
  }
  voiceTranscript.setValue(fullTranscript.trim());
};
```

**Expected result:** In continuous mode, recognition continues across multiple speech segments until the user manually stops it.

## Complete code example

File: `JS Query: startVoiceInput (complete implementation)`

```javascript
// JS Query: startVoiceInput
// Complete voice input implementation with language selection,
// interim results, error handling, and continuous mode toggle

// --- Configuration ---
const USE_CONTINUOUS_MODE = false; // Set to true for long-form dictation
const LANG = recognitionLang?.value || 'en-US';

// --- Browser support check ---
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
  utils.showNotification({
    title: 'Browser not supported',
    description: 'Voice input requires Chrome or Edge. Firefox and Safari do not support the Web Speech API.',
    notificationType: 'error',
  });
  return;
}

// If already recording, stop instead of starting again
if (isRecording.value && window._retoolSpeechRecognition) {
  window._retoolSpeechRecognition.stop();
  return;
}

// --- Create recognition instance ---
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();

recognition.lang = LANG;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
recognition.continuous = USE_CONTINUOUS_MODE;

// Store reference for stopVoiceInput query
window._retoolSpeechRecognition = recognition;

// --- Event handlers ---
recognition.onstart = () => {
  isRecording.setValue(true);
};

recognition.onresult = (event) => {
  let finalTranscript = '';
  let interimTranscript = '';

  for (let i = event.resultIndex; i < event.results.length; i++) {
    const text = event.results[i][0].transcript;
    if (event.results[i].isFinal) {
      finalTranscript += text;
    } else {
      interimTranscript += text;
    }
  }

  if (finalTranscript) {
    // Append final result to existing transcript
    const existing = voiceTranscript.value || '';
    voiceTranscript.setValue((existing + ' ' + finalTranscript).trim());
  } else if (interimTranscript) {
    // Show interim results (overwritten when final arrives)
    // NOTE: For interim-only display, use a separate interimTranscript state
    // This avoids polluting the final transcript with partial results
  }
};

recognition.onerror = (event) => {
  isRecording.setValue(false);
  window._retoolSpeechRecognition = null;

  const errorMessages = {
    'not-allowed': 'Microphone access was denied. Click the lock icon in your browser address bar and allow microphone access, then try again.',
    'no-speech': 'No speech was detected. Ensure your microphone is working and you are speaking clearly.',
    'audio-capture': 'No microphone found. Connect a microphone and try again.',
    'network': 'A network error occurred during recognition. Check your internet connection.',
    'aborted': 'Recording stopped.',
    'service-not-allowed': 'Speech recognition service is not allowed. This may occur on HTTP (non-HTTPS) pages.',
  };

  const message = errorMessages[event.error] || `Recognition error: ${event.error}`;
  utils.showNotification({
    title: 'Voice input failed',
    description: message,
    notificationType: event.error === 'aborted' ? 'warning' : 'error',
  });
};

recognition.onend = () => {
  isRecording.setValue(false);
  window._retoolSpeechRecognition = null;

  if (voiceTranscript.value) {
    utils.showNotification({
      title: 'Recording complete',
      description: `Transcribed: "${voiceTranscript.value.slice(0, 60)}${voiceTranscript.value.length > 60 ? '...' : ''}"`,
      notificationType: 'success',
      duration: 3,
    });
  }
};

// --- Start recording ---
try {
  recognition.start();
} catch (err) {
  isRecording.setValue(false);
  utils.showNotification({
    title: 'Could not start recording',
    description: err.message,
    notificationType: 'error',
  });
}
```

## Common mistakes

- **Trying to set a Text Input's value directly from the JS Query using textInput1.setValue() and expecting it to persist** — undefined Fix: Text Input components in Retool do not have a setValue() method. Instead, use a Temporary State (voiceTranscript.setValue()) and bind the Text Input's Default value to {{ voiceTranscript.value }}. The input will update reactively.
- **Creating a new SpeechRecognition instance every time the user clicks the button without stopping the previous one, causing multiple simultaneous recording sessions** — undefined Fix: Check if a recognition instance already exists on window._retoolSpeechRecognition before creating a new one. If one is running, call .stop() first. The onend callback cleans up the window reference.
- **Setting interimResults = true and writing every interim result to the final transcript state, causing duplicated words as interim results are replaced by final ones** — undefined Fix: In the onresult handler, only append to the final transcript state when event.results[i].isFinal is true. Use a separate state or ignore interim results for the value that gets submitted.
- **Not handling the 'not-allowed' error, leaving users confused when microphone permission is denied** — undefined Fix: In the onerror handler, specifically handle event.error === 'not-allowed' with instructions to click the browser lock icon and enable microphone permissions for the Retool domain.

## Best practices

- Always check for Web Speech API support before calling it — show a clear error message if the user is on an unsupported browser
- Store the SpeechRecognition instance on window._retoolSpeechRecognition so multiple JS Queries (start and stop) can share the same instance
- Use Temporary State for the transcript rather than trying to set a Text Input's value directly — Text Input values cannot be set programmatically in Retool without a default value binding
- Set recognition.continuous = false for short inputs (search fields, single-line forms) and true only for long dictation — continuous mode requires explicit stop and uses more resources
- Show a visual recording indicator (button color change, animated icon) so users know when the microphone is active — silence during recording is confusing
- Always allow users to edit the transcribed text before submitting — Web Speech API accuracy is ~95% in ideal conditions but lower in noisy environments or with accents
- For HTTPS requirement: Web Speech API requires HTTPS in production. Retool Cloud is always HTTPS. Self-hosted Retool must use SSL.

## Frequently asked questions

### Does the Web Speech API work in Firefox or Safari?

Firefox does not implement the Web Speech API — voice input using this tutorial will not work for Firefox users. Safari has experimental support behind a flag but it's unreliable. For cross-browser voice input, use a Custom Component wrapping a third-party speech-to-text API like OpenAI Whisper or AssemblyAI, which work in all modern browsers via HTTP requests.

### Does voice input require any API keys or external services?

No — the Web Speech API is built into Chrome and Edge and uses Google's speech recognition service internally, but this is transparent to you. There are no API keys, no usage limits you need to manage, and no external service to configure. It works as long as the user's browser supports it and they grant microphone permission.

### How accurate is the Web Speech API transcription?

The Web Speech API uses Google's speech recognition engine in Chrome. Accuracy is typically 90-95% for clear speech in a quiet environment with a standard US English accent. Accuracy decreases with background noise, non-native accents, technical jargon, or proper nouns. Always allow users to edit the transcription before submitting — provide a Text Input (not a read-only display) so corrections are easy.

### Can I use voice input to search a table or trigger a query directly?

Yes — instead of binding the transcript to a Text Input, you can use the onresult callback to directly trigger a Retool query when a final transcript arrives. For example, when finalTranscript is captured, call searchQuery.trigger({ additionalScope: { searchTerm: finalTranscript } }) to immediately execute a search. This creates a hands-free search experience.

---

Source: https://www.rapidevelopers.com/retool-tutorial/how-to-use-voice-input-in-retool-applications
© RapidDev — https://www.rapidevelopers.com/retool-tutorial/how-to-use-voice-input-in-retool-applications
