██████╗ ██████╗ ██╗ ██████╗
██╔══██╗██╔═══██╗██║ ██╔═══██╗
██████╔╝██║ ██║██║ ██║ ██║
██╔══██╗██║ ██║██║ ██║ ██║
██████╔╝╚██████╔╝███████╗╚██████╔╝
╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝
Free, self-hosted voice dictation powered by Telnyx AI. Hold a key anywhere to dictate. Release to paste.
Bolo is a macOS push-to-talk app that transcribes your speech and pastes it into any active text field, including Slack, Notion, Gmail, VS Code, terminals, and browsers. No always-on microphone. No subscription.
The name comes from Hindi: "bolo" means "speak."
macOS 12+ · Rust 1.88+ · MIT · Website
# Install manually
git clone https://github.com/a692570/bolo.git && cd bolo && ./install.shIf a Bolo-<version>.dmg is attached to a GitHub release, open it, drag Bolo into Applications, and launch it. No Rust, no Python setup, no terminal. The app bundles a CPython runtime for both Intel and Apple Silicon Macs, creates its helper environment from wheels inside the bundle on first launch, registers itself as a login item, and collects your Telnyx API key in its onboarding window (paste the key and click Validate; nothing is transcribed for the check). One Accessibility grant covers Bolo and all of its helpers because macOS attributes them to the app bundle.
Two macOS caveats, both consequences of shipping without a paid Developer ID:
- Bolo is ad-hoc signed, not notarized. The first launch on macOS 15 is blocked by Gatekeeper; allow it under System Settings > Privacy & Security > Open Anyway.
- Downloads from the releases page are the only supported install; a DMG built on your machine is for testing.
scripts/build-dmg.shThe script builds the universal Rust binary (bootstrapping a private rustup toolchain inside build/ when the system rust cannot cross-compile for x86_64), downloads the pinned python-build-standalone runtimes, resolves the pyobjc wheels, renders the app icon, stages Bolo.app, ad-hoc signs it, packs dist/Bolo-<version>.dmg, and verifies the mounted result. Artifacts are cached in build/ and the DMG lands in dist/; both are gitignored.
AI agent prompt: Copy and paste this into Claude Code, ChatGPT, Cursor, or any coding agent:
"Install Bolo from github.com/a692570/bolo on my Mac. It needs Rust, Python 3, and a Telnyx API key. Run install.sh, then help me grant Microphone and Accessibility permissions."
The install script does the following, in order:
- Creates a private Python helper environment at
~/.bolo/venv - Builds the Rust binary
- Checks for an existing Telnyx API key in
~/.codex/.envor your environment. If none is found, it prompts you and saves the key to~/.bolo/env - Registers Bolo as a login item so it starts automatically
- Starts Bolo
Step 2: Pick your hotkey
On first launch, an onboarding dialog asks which key you want to hold for dictation. Options include Right Option (MacBook built-in), Right Control (external keyboards), F19 (mechanical keyboards), and Caps Lock. Your choice is saved to ~/.bolo/env.
# Change it anytime
export BOLO_HOTKEY="right_control"Step 3: Grant permissions
Bolo needs two macOS permissions in System Settings > Privacy & Security:
- Microphone: Captures audio only while your hotkey is held. Nothing is saved to disk.
- Accessibility: Bolo's Python helper pastes text into other apps. Add this managed interpreter:
echo "$HOME/.bolo/venv/bin/python3"
# Restart after enabling that interpreter in Accessibility
./restart.shStep 4: Dictate
Place your cursor in any text field, hold your hotkey, speak, and release. Text appears at your cursor. Bolo uses the clipboard for paste insertion, then restores the previous clipboard contents by default. Recent transcripts are available from the Bolo menu bar icon when you need to copy one manually.
# Restart anytime
./restart.shBolo checks for updates when it starts from the login item or ./restart.sh.
If the local checkout can fast-forward to GitHub, Bolo pulls the latest code and rebuilds the release binary before launching.
You can also use Check for Updates from the Bolo menu bar icon.
Updates are skipped if local files have uncommitted changes, GitHub is unreachable, Rust is missing, or the checkout cannot fast-forward cleanly.
To disable launch-time update checks persistently, add this line to ~/.bolo/env:
BOLO_AUTO_UPDATE=offBolo's speech pipeline runs entirely on Telnyx-hosted models. Set your Telnyx API key as an environment variable:
export TELNYX_API_KEY="your_key_here"TELNYX_API_KEY is required: every streaming provider, every batch model, and the LLM cleanup fallback below run on it. Add it to your shell profile to persist it, or put it in ~/.bolo/env. The install script writes prompted keys to ~/.bolo/env.
Bolo reads API keys from the process environment first, then falls back to ~/.bolo/env and ~/.zshrc.
The installer keeps ~/.bolo private to your macOS account and stores ~/.bolo/env with owner-only permissions.
Bolo takes a cross-install instance lock with its version and binary path. When a starting instance finds a live lock holder, it arbitrates by version: an older copy refuses to start with a notification naming the newer one, a newer copy terminates the stale instance, takes the lock, and notifies, and equal versions keep the first-wins rule. Locks from pre-arbitration builds carry no version, so a fresh install replaces a legacy running copy. This makes the 2026-10-19 failure class impossible: a stale DMG bundle can no longer win the microphone from the current build after a reboot.
Chromium-based apps (OpenChamber, Slack, Discord, Notion, WhatsApp) ship with renderer accessibility off until an assistive client asks. Bolo sets the private AXManualAccessibility flag on those apps on first read, which turns on the tree Bolo and other assistive tools use for cursor context, per-app cleanup profiles, and edit learning. The first read after a new app launch may still come back empty; every read after it sees the tree.
Bolo redacts dictated text from /tmp/bolo.log by default. To temporarily log transcript text while debugging, set:
export BOLO_LOG_TRANSCRIPTS="on"To change the push-to-talk hotkey, set:
export BOLO_HOTKEY="right_option"Supported values:
right_option(default): Right Option keyleft_option: Left Option keyright_control: Right Control keyright_shift: Right Shift keyfn: Fn keyf1throughf19: Function keyscaps_lock: Caps Lock key
To paste the latest transcript again with a separate hotkey, set:
export BOLO_PASTE_LAST_HOTKEY="f19"This is off unless configured. It accepts the same key names as BOLO_HOTKEY where supported, and uses the clipboard only long enough to paste before restoring the previous clipboard text.
A watchdog auto-releases a recording that runs longer than 30 seconds, which protects against a stuck recording but also caps a single dictation. To raise (or lower) that ceiling, set:
export BOLO_MAX_RECORDING_SECONDS="180"The value is in seconds and defaults to 30. A missing, zero, or unparseable value keeps the 30-second default.
To preselect a microphone without using the menu, set:
export BOLO_MICROPHONE="Microphone name"Bolo conditions microphone audio with WebRTC's audio processing (noise suppression, AGC, echo canceller) before sending it to STT. To disable that stage and send raw microphone audio, set:
export BOLO_AUDIO_CONDITIONING="off"Conditioning is on by default. It keeps the same number of samples and alignment as the raw stream; if the processing engine fails to initialize, Bolo logs it and records without conditioning.
Bolo can also transcribe offline through a local whisper.cpp model. The tier trails the whole cloud ladder (BOLO_LOCAL_STT=auto, the default): it runs only when streaming and the batch retry both fail or return no words. A streaming session that dies mid-dictation routes release straight to the offline tier: the WebSocket's death is itself the failure signal, so no refine call or batch attempt runs, and the overlay shows a "Recovered offline" note while the local ladder works. To skip cloud entirely and dictate offline, set:
export BOLO_LOCAL_STT="on"Offline-only dictations get live partials too: while the key is held, the parakeet rung transcribes a trailing ~4-second window of the capture about once per second and shows it on the recording pill, so fully offline dictation still shows text growing as you speak. Previews are never final, and one failed sidecar call ends the preview for that dictation; the full-clip release ladder still runs, and its result wins.
To never consult the local tier, set BOLO_LOCAL_STT="off". Transcription runs on a Metal-accelerated local model (default: ggml-small, ~466 MB) stored under ~/.bolo/models; fetch it with Download Offline Model... under the Bolo menu's More..., which verifies the download's sha256 against the published digest and logs progress every 10%. Dictations the offline tier rescues are marked inserted_local in the [metrics] dictation_latency log line, so latency attribution stays clean. To point at a different ggml model file already in ~/.bolo/models, set:
export BOLO_LOCAL_WHISPER_MODEL="ggml-base.bin"Before whisper.cpp, the offline ladder first tries parakeet-mlx, a resident MLX sidecar running mlx-community/parakeet-tdt-0.6b-v3 on the Apple Silicon GPU. On the 134-clip dictation benchmark harness (2026-10-17, laneG), it measured a 13.6% median WER, beating Bolo's own cloud batch default (nova-3, 14.3%), at 0.086 s median per clip and 0.755 s cold start with a warm Hugging Face cache. It also formats natively (94.8% end punctuation, 100% capitalized sentence starts). Setup is one command, once:
./ensure-python-env.sh --parakeetThat creates ~/.bolo/parakeet-venv (Python >= 3.10, required by parakeet-mlx 0.5.3; pinned MLX 0.32.4) and never runs at app startup. The rung is on by default and engages whenever the offline ladder runs and the dictation language hint starts with en; set BOLO_LOCAL_PARAKEET="off" to disable it and keep the whisper path unchanged.
Three caveats from the benchmark, by design:
- English-only. parakeet-tdt-0.6b-v3 is English-trained; it maps non-English speech onto the nearest English words instead of failing (a Devanagari clip came back as "Jeff."). The rung only engages for English hints, so
hi,auto, and other languages keep whisper's multilingual detection. - Noise gate. Like every other engine measured, parakeet invents words on silent input (12/12 silent/noise clips produced hallucinated text). Both the Rust pipeline and the sidecar re-check the same speech-energy threshold (any 20 ms frame at RMS >= 0.006) before inference, so silence falls to the existing no-speech handling instead of inserting words.
- Memory. The sidecar holds ~118 MB resident after load (the 2.4 GB weights are lazily mmapped) and peaks around 1.6 GB while transcribing. It starts lazily on the first offline dictation and dies with the app, so the cost is paid only while the offline tier is in use.
When parakeet cannot serve a clip (a Hindi dictation, auto detection, a missing parakeet venv, or an empty parakeet answer), the ladder next tries qwen3-asr-0.6B (package qwen3-asr-mlx), a resident MLX sidecar running mlx-community/Qwen3-ASR-0.6B-bf16. On the same audio, the 2026-10-18 benchmark measured the best conversational Hindi of every candidate (52.0% WER with native Devanagari output; whisper-small 60.4%, whisper-large-v3-turbo 68.5%, parakeet romanizes and is disqualified) with English content accuracy on par with whisper-large-v3-turbo, at 0.166 s warm per clip and ~0.34 s load from a warm Hugging Face cache. Clips 6 seconds or longer lead with qwen ahead of parakeet (the accuracy rung), while shorter clips keep parakeet's 0.116 s speed. Setup mirrors the parakeet rung:
./ensure-python-env.sh --qwenThat creates ~/.bolo/qwen-venv (Python >= 3.10, pinned qwen3-asr-mlx 0.3.0) and never runs at app startup. The rung is on by default and engages for en, hi, and auto language hints; set BOLO_LOCAL_QWEN="off" to disable it, or BOLO_QWEN_MODEL to point at a different HF snapshot. The ~1.2 GB weights download on first use through huggingface_hub (the same cache parakeet rides), or up front from Download Qwen ASR Model... under More.... The sidecar re-runs the exact same speech-energy gate before inference, mirrors parakeet's lazy-spawn lifecycle, and frees the model on exit; Bolo's vocabulary rides as the model's context prompt (the same first-50-terms cap as the cloud prompt).
Bolo learns your corrections. When a dictation lands and you edit it within 15 seconds — backspacing and retyping a word Bolo misheard — the next dictation you fix that way teaches Bolo the pair: it is saved to ~/.bolo/learned_vocabulary.json (capped at 100 entries), applied on every future dictation at zero latency, and Bolo shows a "Bolo learned: X → Y" notification the first time a pair is saved. Only clean single-word substitutions are learned; deletions and full rewrites are deliberately ignored. The tray menu's Learned Words shows everything learned so far, and the dashboard counts them.
When Bolo cannot find an editable paste target (no text field is focused, or the paste fails), it skips the blind paste, copies the transcript to the clipboard, and shows a popup with the transcript instead: edit it right there if you like, click Copy to take the edited version, or Dismiss. Edits made inside the popup are learned exactly like paste edits, so a correction you make in the popup follows every future dictation.
The default model is openai/whisper-large-v3-turbo, transcribed through the Telnyx STT API. Bolo sends your vocabulary terms along as a free-text prompt with an echo filter, and whisper models decode language automatically per segment so code-switched dictation keeps working. The verbatim transcript then goes through Bolo's local pass (filler removal, vocabulary and replacements) plus the optional LLM cleanup below. The separate LLM cleanup pass always runs on the verbatim transcript when enabled. A deterministic formatter then polishes casing and punctuation spacing (capitalize sentence starts and standalone "i", tighten punctuation spacing) without changing any word, so final-transcript formatting stays at streaming quality regardless of which engine produced the words.
To test another model, set:
export BOLO_STT_MODEL="deepgram/nova-2"
export BOLO_STT_FALLBACK_MODEL="openai/whisper-large-v3-turbo"Every BOLO_STT_MODEL value routes through the Telnyx STT API and requires TELNYX_API_KEY.
For accent or language hints, set:
export BOLO_STT_LANGUAGE="en-IN"Bolo also exposes this from the menu bar under Language. auto maps to Deepgram multi-language mode and disables the hint for Whisper-style fallback models.
If a non-US language hint makes recognition slow, Bolo automatically switches back to English, US after that dictation and shows a notification. This keeps accent experiments from making the app feel stuck.
For provider fallback, set a comma-separated chain:
export BOLO_STT_FALLBACKS="xai,telnyx:openai/whisper-large-v3-turbo"Supported fallback entries:
telnyx:<model>(or a bare model name) reuses the existing Telnyx API key with that model.xaiusesXAI_API_KEYand xAI's REST STT API.
When neither BOLO_STT_FALLBACKS nor BOLO_STT_FALLBACK_MODEL is set, the default chain is telnyx:openai/whisper-large-v3-turbo. Set BOLO_STT_FALLBACKS=off to fail fast instead of retrying when the primary model is rate limited. BOLO_STT_FALLBACK_MODEL still works for a single fallback model of the same shape.
Streaming starts sending audio while you speak and falls back to batch transcription when a complete result is not ready quickly enough after release. Streaming defaults to Deepgram regardless of which batch model you pick; the live preview and the final transcript are deliberately decoupled (the 2026-10-17 measurement scored Deepgram flux streaming at 16.7% median WER and batch whisper-large-v3-turbo at 11.3% on 134 real dictations, so the stream stays the live preview while batch decides the final transcript).
Set BOLO_STT_STREAMING to pick a provider explicitly:
export BOLO_STT_STREAMING="telnyx"Supported values (all Telnyx-routed, all requiring TELNYX_API_KEY):
telnyx(alsodeepgram,nova-3, ornova3): Deepgram Nova-3 streaming through the Telnyx websocket.assemblyai-telnyx(alsoassembly-telnyx): AssemblyAI Universal-3.5 streaming routed through Telnyx, pinned toassemblyai/universal-3-5-pro.off: always use batch transcription.
Inside the Telnyx websocket, BOLO_TELNYX_STREAMING_ENGINE and BOLO_TELNYX_STREAMING_MODEL select the engine and model (defaults: Deepgram and flux-multi — the lowest-latency Deepgram family, with Hindi code-switching and vocabulary as keyterms):
export BOLO_TELNYX_STREAMING_ENGINE="Speechmatics"
export BOLO_TELNYX_STREAMING_MODEL="streaming-exp"The documented engines are Deepgram, Telnyx, Google, Azure, xAI, AssemblyAI, Speechmatics, Soniox, Parakeet, Cohere, Reson8, and Humain; unknown values pass through verbatim so new engines work without a code change. Live partial transcripts (interim results) come only from Deepgram, Speechmatics, and Soniox; the other engines return a single final transcript when you release. The assemblyai-telnyx provider ignores these two selectors and pins AssemblyAI's own engine and model.
After a healthy streaming result, Bolo re-runs the batch model over the same audio inside a bounded window and inserts its transcript when it answers with words (BOLO_FINAL_REFINE, on by default). The streaming text ships unchanged when refine is disabled, times out after two seconds, or returns empty. Set BOLO_FINAL_REFINE=off to insert the streaming text directly and skip the round trip:
export BOLO_FINAL_REFINE="off"The refine model is deepgram/nova-3 by default (BOLO_FINAL_REFINE_MODEL): it must answer inside the paste budget, and the 2026-10-17 live run measured whisper-large-v3-turbo above 2 seconds wall on the production connection, which exhausts the window and falls back to the stream anyway. The batch primary (BOLO_STT_MODEL, default openai/whisper-large-v3-turbo) stays the no-streaming path, where no window applies.
To keep dictated text on the clipboard after insertion, set:
export BOLO_PRESERVE_CLIPBOARD="off"By default, Bolo restores whatever was on your clipboard before dictation, including non-text pasteboard contents when macOS allows it. The dictated text is still kept in Bolo's correction state for commands like scratch that and actually ....
Follow-up edits are guarded by the current cursor position. scratch that and actually ... only change text when the previous dictation is still immediately before the caret and still matches exactly. If the cursor moved or the text changed, Bolo leaves the document alone.
You can also transform the previous dictation by voice:
Bolo, polish that
Bolo, prompt that
polish that tightens grammar and flow without changing meaning or confidence. prompt that restructures the dictation into a concise goal and supporting requirements. Both commands use the configured LiteLLM or Telnyx rewrite path, and both refuse to act if the previous dictation moved or changed.
Bolo also stores your 10 most recent transcripts locally at ~/.bolo/transcripts.json. Use the menu bar icon to copy the latest transcript or a recent transcript into the clipboard on demand. When cleanup changes the text, Bolo keeps both the raw STT transcript and the cleaned transcript so you can copy either one from history. If you immediately edit inserted text with Backspace or Cmd+A, Bolo marks only that history item as edited and logs a content-free quality signal.
Use Clear Transcript History from the menu bar to delete local transcript history, or Run Health Check to confirm Bolo can see microphones, the active language, STT mode, and history state.
To rewrite text already in another app, select the text, open the Bolo menu bar icon, and choose Rewrite Selected Text.... Bolo captures the selected text through macOS Accessibility, asks what change you want, runs the same LLM backend, then pastes the replacement back into the original app.
LLM cleanup defaults to auto: short clean dictations skip the LLM, while longer or messy dictations get a formatting-only pass that fixes punctuation, capitalization, and paragraph breaks. It never adds, removes, replaces, or reorders words; a strict verbatim gate rejects any LLM output whose content tokens differ from the dictation and keeps the pasted text instead (the 2026-10-17 measurement showed word-level cleanup costs accuracy: 10.6% to 14.3% median WER on the best engine). Devanagari dictations bypass the gate so the built-in Hinglish transliteration still works. Set BOLO_CLEANUP_GATE="off" to restore the legacy permissive cleanup.
To force LLM cleanup for every dictation, set:
export BOLO_LLM_CLEANUP="on"To disable LLM cleanup entirely, set BOLO_LLM_CLEANUP="off".
LLM cleanup and rewrites resolve their endpoint in this order: LITELLM_BASE (any OpenAI-compatible endpoint) first, using Kimi-K2.5 unless BOLO_LLM_MODEL is set; then the Telnyx inference endpoint (Qwen/Qwen3-235B-A22B, thinking disabled) is used only while TELNYX_API_KEY is set. With no endpoint available at all, LLM cleanup is disabled and local cleanup still runs on its own, and voice rewrites report missing configuration. MiniMax is intentionally not used for cleanup because it can leak reasoning text into the output.
When LLM cleanup runs, Bolo also reads the frontmost app and nearby cursor text through macOS Accessibility so cleanup can choose natural spacing, capitalization, and continuation. That context is used only for cleanup prompting.
Cleanup style is selected automatically for common email, chat, and notes apps. To override an app, use Set Cleanup Style for Current App... from the menu bar and enter default, email, chat, or notes.
Overrides are saved in ~/.bolo/prompt_bindings.json:
[
{
"bundle_id": "com.tinyspeck.slackmacgap",
"app_name": "Slack",
"profile": "chat"
}
]You can also add personal vocabulary in ~/.bolo_vocabulary.json as a JSON string array, for example:
["Release note", "Remotion", "Telnyx", "Abhishek"]You can also teach Bolo common mishears with alias objects:
[
"Telnyx",
{
"text": "Claude",
"aliases": ["cloud", "claud"]
},
{
"text": "cron",
"aliases": ["chrome"]
}
]Bolo merges that with its built-in vocabulary and uses it to preserve known terms more reliably. Aliases are applied locally before LLM cleanup, so common accent or model mistakes are fixed without adding latency.
You can add vocabulary from the menu bar with Add Vocabulary Term... and add mishear aliases with Add Vocabulary Alias.... New terms are saved to ~/.bolo_vocabulary.json and start applying immediately.
For deterministic phrase fixes after transcription, add replacements in ~/.bolo/replacements.json:
{
"voice ai": "Voice AI",
"opsen sourced": "open sourced"
}Replacements are applied after local cleanup and before text insertion. Longer replacement triggers win before shorter ones.
You can add these from the menu bar with Add Correction Rule.... Rules are saved to ~/.bolo/replacements.json and apply to the next dictation.
You can also add a correction by voice:
Correct tab only to Tavily
Bolo saves that as a local replacement rule and does not paste the command text.
- Bolo is under active development and improving quickly.
- Short dictation works well today. Longer dictation and latency are still improving.
- Long dictation still needs more real-world validation than short phrases.
- Cleanup is intentionally conservative to preserve literal meaning.
- A first-run onboarding dialog asks for your preferred hotkey so you never start with the wrong key.
- Bolo rechecks OS key state while running so missed hotkey press or release events are corrected quickly.
- A recording lifecycle state machine ignores duplicate press and release events and keeps stale-recording recovery predictable.
tail -f /tmp/bolo.logEach dictation logs the pipeline stages: audio metadata, speech detection metrics, Telnyx STT endpoint/model/request metadata, raw STT transcript, local cleanup transformations, LLM cleanup endpoint/model/input/output when enabled, and the final text sent for insertion. Authorization headers, API keys, and clipboard contents are not logged.
To run the small phrase-based evaluation harness:
cd bolo
python3 eval_dictation.py prompts
python3 eval_dictation.py init > eval_results.jsonFill actual in eval_results.json with what Bolo produced for each phrase, then score it:
python3 eval_dictation.py score eval_results.json- How does this compare to Wispr Flow?
Bolo is a free open source alternative with similar push-to-talk mechanics. Both use global hotkeys and cloud STT. Bolo uses Telnyx APIs and is fully transparent in audio handling.
- Is my audio stored or used for training?
Audio is sent to Telnyx APIs for transcription and immediately discarded. Bolo processes audio in memory only. Text transcripts are stored locally at ~/.bolo/transcripts.json for menu bar history.
- Can I change the hotkey from Right Option?
Yes. Set BOLO_HOTKEY to right_control, right_shift, fn, f1 through f19, or caps_lock. See Configuration above.
- Does it work offline?
Only through the optional offline tier: BOLO_LOCAL_STT="on" dictates entirely on-device, trying the parakeet MLX sidecar first, then the qwen3-asr MLX sidecar, and finally a local whisper.cpp model (see Configuration). The default cloud pipeline needs internet to reach Telnyx APIs.
- Why Rust?
Rust gives Bolo a single compiled runtime with strict linting, safe audio capture, and no app-level unsafe code.
- How do I correct a mistake?
Say Correct heard phrase to desired phrase, or use Add Correction Rule... from the menu bar. Bolo stores the rule locally in ~/.bolo/replacements.json.
-
No text appears after releasing hotkey: Check
/tmp/bolo.log. VerifyTELNYX_API_KEYis set. Ensure~/.bolo/venv/bin/python3is enabled in Accessibility, then run./restart.sh. -
Paste stops working after an update: Run
./install.shto verify the managed helper, enable~/.bolo/venv/bin/python3in System Settings > Privacy & Security > Accessibility, then run./restart.sh. Bolo reports helper failures separately from missing permissions. -
Audio not recording: Verify Microphone permission is granted in System Settings. Run
./restart.shafter granting permissions so macOS re-prompts cleanly. -
Multiple Bolo processes appear: Run
./restart.sh. -
Bolo does not appear in the menu bar: Check
/tmp/bolo.logfor menu initialization errors and verify you are running the latesttarget/release/bolo. -
High latency: Check network connectivity. Longer utterances currently use batch finalization.
MIT