CtrlK
BlogDocsLog inGet started
Tessl Logo

voice-transcription

Framework-wide voice dictation in the agent sidebar composer. Use when changing composer microphone UX, the transcribe-voice route, or the Voice Transcription settings section. Covers transcription-source routing, cleanup routing, Google realtime gating, and the voice transcription application-state keys.

67

Quality

82%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

Voice Transcription

The microphone inside the sidebar composer offers two distinct paths: editable dictation and an opt-in realtime speech-to-speech agent session. Users configure dictation separately from AI cleanup in Settings → Voice Transcription. Both paths are available in every template that renders the shared agent sidebar.

UX rules

  • Always show the mic alongside the send button. Cursor replaces send with mic when the composer is empty; their users complain. We keep both visible — Lovable does the same.
  • Ask before switching modes. The mic opens a compact shadcn popover with Real-time voice as the primary action and Dictate as the secondary; never silently replace the established dictation behavior. Users can choose Remember my preference to skip the chooser on later clicks.
  • Dictation is click-to-toggle, not push-to-talk. More forgiving in a sidebar, avoids host-app hotkey clashes. Keyboard shortcut is Cmd/Ctrl+Shift+M and Escape cancels mid-recording.
  • Transcript lands in the composer, editable, never auto-sent. Insert at the caret via editor.chain().focus().insertContent(text).run().
  • No CSS transitions for the recording state. Framework rule; use static brand color (#625DF5) instead of pulses.
  • Icon: Tabler IconMicrophone (idle) / IconPlayerStopFilled (recording). Never use a sparkle or robot icon.
  • Errors via inline alert or toast, never window.alert.

Realtime speech mode

Realtime speech mode is a full-duplex OpenAI Realtime WebRTC session, separate from the dictation providers below. It prefers the authenticated user's complete Builder connection and the Builder-managed metered gateway. A scoped OPENAI_API_KEY is the fallback when Builder is not connected. The server owns the gpt-realtime-2.1 session configuration and keeps credentials out of browser code. If neither provider is configured, show Connect Builder as the primary microphone-popover action and OpenAI key setup as the secondary action before requesting microphone permission.

  • Starting voice mode collapses the chat into a persistent bottom-end speech orb. The orb stays visible above the chat even when the chat opens automatically. Clicking it shows or hides chat without ending the session; opening chat slides the orb clear of the sidebar. The progressively disclosed controls contain only settings and the separate end-session action.
  • The orb's compact waveform stays visible for the full session and reflects actual microphone or assistant audio activity. At silence it rests at its baseline instead of turning into a loading spinner. Once connected, a soft, slowly rotating blue glow marks the live session; brighten and speed up the glow while the agent is working, and keep it static under reduced motion. While connecting, show a distinct compact spinner; when the session becomes live, the assistant gives one brief spoken greeting so readiness is unambiguous. Do not apply a text-sized max_output_tokens cap to that audio greeting because audio tokens can truncate it mid-sentence.
  • The orb settings cog opens in place without ending voice mode or navigating away. Nested pickers must not dismiss the settings popover. Language and intelligence update the active Realtime session; the microphone picker swaps the live WebRTC input track and remembers the browser-local device choice; an output-voice change updates immediately only before the assistant has emitted audio and otherwise applies to the next conversation, matching the Realtime API's voice immutability rule.
  • Semantic VAD keeps listening, starts responses automatically, and supports barge-in while the agent is speaking.
  • Function calls must cross the authenticated realtime tool bridge and enter executeAgentToolCall. Never call ActionEntry.run directly: that bypasses schema validation, approvals, audit/journal behavior, timeouts, redaction, and mutation refreshes.
  • Preserve the active browser-tab id in request context so set-url-path, set-search-params, view-screen, and tab-scoped application state affect the app the user is actually speaking to. Realtime tool manifests are capped, so prioritize navigate, set-url-path, set-search-params, and view-screen before packing large template registries.
  • Keep tool-search in the initial Realtime manifest. A successful specific search may expand the live session with matching action schemas, but the server must derive those schemas from its own registry, bind authorization to the authenticated voice session, and continue execution through executeAgentToolCall. Treat session.update.tools as a full replacement: preserve the pinned navigation/discovery tools, evict lower-priority entries within the manifest cap, and wait for session.updated before asking the model to continue with a newly discovered tool.
  • Do not persist audio or interim transcript deltas. Append completed user and assistant utterances as ordinary text messages to the exact chat thread captured when the session starts. Input transcription completes asynchronously from assistant generation, so reserve provider conversation item order and publish only the contiguous completed sequence; never use completion arrival time as chat order. Ending voice mode opens that chat so the user can continue over text. Store only compact lifecycle and latest context at application_state["realtime-voice-session"]; delete it when the session ends.
  • Store the user-controlled language, intelligence, and output-voice choices at application_state["realtime-voice-prefs"]. Keep this separate from voice-transcription-prefs, which configures editable dictation rather than the speech-to-speech session.
  • Store the optional remembered chooser selection at application_state["voice-input-preference"]. Keep it separate from both mode-specific preference objects.
  • Missing-key failures should open Settings focused on the user-scoped OPENAI_API_KEY field. Never send, log, or echo the key to the browser.

Source And Cleanup

Settings must keep these as separate choices:

  • Live transcription source: mac-native, google-realtime, or batch.
  • AI cleanup: independent off/on toggle. Cleanup uses managed Gemini first when a managed AI services connection is configured, then BYOK Gemini (GEMINI_API_KEY). Gemini cleanup/title/summary generation is not a live STT source.

application_state["voice-transcription-prefs"] stores { transcriptionMode, provider, instructions }. The legacy provider field is still written for old clients and batch provider preferences:

ValueMeaningNeeds key
mac-nativeNative macOS/Tauri speech path; web clients normalize to browser-native where neededNo
google-realtimeDedicated WebSocket → Google Speech-to-Text gRPC StreamingRecognize pathGOOGLE_APPLICATION_CREDENTIALS
batchUpload audio after stop through the existing batch routeBuilder/Gemini/Groq/OpenAI depending on fallback
auto providerBrowser SpeechRecognition when supported; server batch fallback chain otherwiseNo key needed in browsers that support SpeechRecognition
builder-geminiManaged Gemini Flash-Lite batch/cleanup preferenceManaged AI services account connected
geminiDirect Google Gemini BYOK batch/cleanup preferenceGEMINI_API_KEY
groqGroq Whisper batch preferenceGROQ_API_KEY
openaiOpenAI Whisper batch preferenceOPENAI_API_KEY
browserLegacy native/browser live speech preferenceNo

Default behavior:

  • The shared web settings/composer default to Batch / auto. In auto mode, useVoiceDictation uses startBrowser() (Web Speech API, no key required, incremental streaming) when the browser supports SpeechRecognition. It only falls back to the MediaRecorder → server upload path when SpeechRecognition is not available (e.g. Firefox). This means dictation works out of the box in Chrome, Edge, and Safari without any API key configuration.
  • Dedicated macOS Tauri-native surfaces may save mac-native, but do not assume the shared React settings default to it.
  • Old stored builder values are treated as builder-gemini.
  • Old stored browser values are treated as mac-native.
  • Saved google-realtime preferences must never hit /_agent-native/transcribe-voice. They go through the dedicated session bridge POST /_agent-native/transcribe-stream/session, which mints an opaque ai-services websocket session and keeps the Google service-account JSON off the client.
  • In the current bridge, the Google option is only actually ready when both the user's GOOGLE_APPLICATION_CREDENTIALS secret exists and a managed AI services connection is configured, because the framework mints the managed ai-services session before streaming begins.

Where the pieces live

FilePurpose
packages/toolkit/src/composer/useVoiceDictation.tsProvider-routing hook (MediaRecorder / Web Speech)
packages/toolkit/src/composer/VoiceButton.tsxMic button + live amplitude + cancel overlay
packages/toolkit/src/composer/RealtimeVoiceMode.tsxOpt-in popover + persistent speech orb
packages/toolkit/src/composer/useRealtimeVoiceMode.tsxWebRTC lifecycle, provider events, and tool bridge
packages/toolkit/src/composer/TiptapComposer.tsxWires the hook, insertion, and keyboard shortcut
packages/core/src/client/settings/VoiceTranscriptionSection.tsxLive source + cleanup controls in sidebar settings
packages/core/src/client/transcription/BuilderTranscriptionCta.tsxCTA shown when Builder account isn't connected
packages/core/src/client/transcription/use-live-transcription.tsWeb Speech live-transcription hook for recordings
packages/core/src/server/transcribe-voice.tsRoute handler (routes to Builder/Gemini/Groq/Whisper)
packages/core/src/server/realtime-voice.tsAuthenticated OpenAI SDP and action-tool routes
packages/core/src/transcription/builder-transcription.tsBuilder proxy transcription client
packages/core/src/voice/Shared voice context pack, prompt, and replacement helpers
packages/core/src/secrets/register-framework-secrets.tsFramework-level provider key registration

Key resolution (server)

transcribe-voice.ts is batch-only. Do not add realtime streaming to this route. Google Speech-to-Text realtime uses a dedicated audio-frame protocol: client audio frames → /_agent-native/transcribe-stream/session → ai-services WebSocket → Google gRPC StreamingRecognize → partial / final transcript events. Use the canonical docs URL: https://cloud.google.com/speech-to-text/v2/docs/streaming-recognize

Batch routing is based on the user's provider preference:

  1. If builder-gemini and resolveHasBuilderPrivateKey() → calls transcribeWithBuilder({ model: "gemini-3-1-flash-lite" }) via Builder proxy, or uses Builder Gemini Flash-Lite to clean up a live native/browser transcript when the desktop client sends text instead of audio.
  2. If builder and resolveHasBuilderPrivateKey() → legacy alias; prefer builder-gemini.
  3. If gemini → resolves GEMINI_API_KEY and calls the direct Google Gemini path.
  4. If groq → resolves GROQ_API_KEY and calls Groq's Whisper-compatible endpoint.
  5. If openai → resolves OPENAI_API_KEY:
    • readAppSecret({ key: "OPENAI_API_KEY", scope: "user", scopeId: session.email }) — user's encrypted secret.
    • resolveCredential("OPENAI_API_KEY") — env var + SQL settings fallback.

In auto mode / no preference, the route tries Builder Gemini Flash-Lite first when Builder is connected, then Gemini BYOK, Groq, and OpenAI. When a request includes instructions, pass them through to the selected LLM provider. Gemini uses them in the transcription prompt, Builder receives them as transcription/cleanup instructions, and Whisper-compatible providers receive them as provider prompt/context.

Requests may also include a multipart voiceContext JSON field. Parse it with parseVoiceContextPack() from @agent-native/core/voice and pass the resulting context through buildVoiceGuidanceBlock(); do not hand-roll separate prompt formats. The context pack is for bounded snippets, active references, route/page metadata, and preferred vocabulary/replacements only. Browser-native and Google realtime final text can POST { text, provider, instructions, voiceContext } to /_agent-native/transcribe-voice when AI cleanup is enabled; batch audio can send the same voiceContext field with its audio upload.

Never hardcode a shared key. Never log the value. Never echo it back to the client.

Overriding per-template

Templates can:

  • Disable the mic: pass voiceEnabled={false} to TiptapComposer.
  • Replace the button: wrap TiptapComposer and render your own extraActionButton; the framework mic stays immediately before the primary send/stop control. Use stopButton for an active-run control so the disabled send button becomes stop when the draft is empty and returns when the user types.
  • Pre-register provider keys as required: true: call registerRequiredSecret(...) from your own server plugin when a template needs a specific BYOK provider in onboarding.

Don'ts

  • Don't call transcription providers from the client — go through /_agent-native/transcribe-voice so the user's secret stays server-side.
  • Don't remove the cancel affordance — mic permission abuse paranoia is real.
  • Don't auto-submit the transcript — users always edit before sending.
  • Don't route realtime tool calls around the central guarded agent executor.
  • Don't persist realtime audio or put provider keys in SDP/session payloads.
  • Don't copy Cursor's "hide send when empty" pattern — it confuses users.
Repository
BuilderIO/agent-native
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.