Three hotkeys, thirty agents — how I route my voice across an AI fleet

Pavel Taran · Written April 30, 2026 · 8 min read

I have ~30 AI agents running in parallel right now. Some on my laptop, some on a remote Linux box, some are HTTP endpoints I deployed on Vercel.

I talk to all of them with three hotkeys.

Here's the setup.

The "more agents" problem

I run a development workflow that would have been absurd two years ago:

Total active "workers" at any moment: ~30. Most idle, some busy, all ready to receive a task.

This setup is fast if I can give each agent its work efficiently. It's unbearable if I have to type-and-paste between windows. The bottleneck stops being the agents (they're infinitely patient) and starts being the human input pipe.

For a few months I tried to solve this with elaborate keybindings, tmux tab discipline, and tabbed terminals. It works, but it requires constant context-switching: which window am I aimed at? which prompt is going to which agent?

Voice fixed it. But not "voice" the way most dictation apps do voice.

I needed voice with channels.

The pulpit metaphor

Think of how a TV studio control room works. The director has a switcher with rows of buttons. Each button routes the live signal to a different destination — to-air, to-tape, to-monitor. The director doesn't move from the chair. They speak into a mic, push a button, the right thing happens in the right place.

That's the pattern I wanted. I'm the director. The agents are my crew. I want a switcher.

So:

In practice:

⌘ Right Command (just hold)   →  text appears at cursor (any app, any field)
⌘ + 1                         →  SSH session to main orchestrator on Linux
⌘ + 2                         →  POST to Linear API as new issue (auto-generates title + body JSON)
⌘ + 3                         →  POST to internal HTTP endpoint that triggers a deployed agent
⌥ (alt) hold                  →  dictate, but pass through a "polish" transformer first

Three modifiers, infinite destinations. The hotkey is the channel.

The secret weapon: a local LLM in the middle

The reason this works is that the dictation isn't just audio → ASR → text → cursor. It's audio → ASR → text → optional transformer → routed output.

Each hotkey can have its own pipeline:

⌘+space (cursor):

audio → Whisper → raw text → typed at cursor

⌘+1 (SSH agent):

audio → Whisper → raw text → typed at active terminal (which happens to be my SSH session)

⌘+2 (Linear issue):

audio → Whisper → raw text →
  local LLM with prompt:
  "Convert this dictation into a Linear issue.
   Output JSON: {title: string, description: string, labels: [string]}.
   Pick a 5-7 word title. Keep description verbatim from dictation."
  → JSON → POST to Linear API → URL of created issue copied to clipboard

⌘+3 (deployed HTTP agent):

audio → Whisper → raw text → POST to https://my-agent.vercel.app/dispatch
  with body { task: <text>, source: "voice", urgency: "normal" }
  → response status logged in menu bar

⌥ (alt-hold polish):

audio → Whisper → raw text →
  local LLM with prompt:
  "Remove filler words (um, uh, like, you know). Replace casual phrasings
   with cleaner equivalents. Preserve the tone. Don't add or remove information."
  → cleaner text → typed at cursor

The transformer step is what makes voice useful for more than dictation. Raw transcription is fine for "type this paragraph". For "create a Linear issue" or "send a structured payload to my agent fleet", you need the dictation packaged into the right shape. The local LLM is doing that packaging.

It costs a few hundred milliseconds per use (model is small, runs on the same Apple Silicon chip). For interactive routing, that's invisible.

A real example

Yesterday I was pacing the kitchen. Bluetooth headphones, laptop closed in the office. I'd just had a debugging insight about a webhook I shipped earlier in the week.

I hold ⌘+1:

"Hey orchestrator, the LemonSqueezy webhook is failing intermittently because the HMAC verification uses constant-time comparison correctly but I think we're constructing the signing payload from the parsed JSON instead of the raw body. Can you read the handler in src/routes/webhooks/lemonsqueezy.rs, check if we're using req.body() raw or serde_json::from_str(req.body()), and if it's the parsed one, fix it to use the raw bytes plus add a unit test that covers a mutated-payload scenario? Also check if we have a similar issue in the Stripe webhook."

A few minutes later, while still pacing, I press ⌘+2:

"Add a Linear issue. Title roughly 'audit all webhook HMAC verifiers for raw-body signature payload bug'. Description: same content as above plus mention this is a security-adjacent issue and should be P1."

Linear issue gets created. URL is on my clipboard. I never opened a window.

This kind of interaction is the actual product. Not the dictation. The routing. The dictation is just the first millimeter of the pipe.

Configurable as plain text (for AI agents)

Here's the part where the tool feeds itself.

The configuration for all of this lives in a plain text file at ~/.config/speechbutton/config.toml. Here's what a slice of it looks like:

[hotkey.cmd_2]
modifier = "cmd"
key = "2"
mode = "press_to_talk"
transformer = "local_llm"
transformer_prompt = """
Convert this dictation into a Linear issue.
Output JSON: {title: string, description: string, labels: [string]}.
Pick a 5-7 word title. Keep description verbatim from dictation.
"""

[hotkey.cmd_2.output]
type = "http_post"
url = "https://api.linear.app/graphql"
headers = { Authorization = "Bearer ${LINEAR_API_KEY}" }
body_template = """
mutation {
  issueCreate(input: {
    title: "${title}",
    description: "${description}",
    teamId: "${LINEAR_TEAM_ID}"
  }) { issue { url } }
}
"""

In the same directory there's a CLAUDE.md that documents the schema. It tells the agent which fields exist, what each does, and provides examples of common patterns.

This is intentional. When I want to add a new hotkey, I don't open the SpeechButton settings UI. I open Claude Code in that directory and tell it:

"Add a hotkey ⌘+4 that takes the dictation, asks the local LLM to summarize it into a one-line commit message in conventional commits format, and types the result at my cursor."

Two seconds later, Claude has read the schema, written the config block, validated it, and saved it. The hotkey works on the next dictation.

This is what I mean when I say the tool was built for people who already work with AI agents. Not "built with AI". Built for use by AI. The agent is the configuration interface.

For developers who don't want to touch config files: there's a UI too. But the UI is for humans who don't have an agent fleet yet. The config-as-code path is the power user path, and it's the one I use daily.

Why on-device

A note about privacy and latency, since this comes up.

The audio path is 100% on-device. Your voice data never leaves the Mac. The ASR is Apple Neural Engine inference of a Whisper-family model. The optional "polish" or "JSON-shape" transformer can be either local (small LLM running on the same chip) or remote (Anthropic, OpenAI). Local is the default.

This isn't a privacy claim for marketing copy. It's a latency claim. Cloud round-trips cost 100-300ms minimum. For the cursor-paste use case, that's the difference between "feels instant" and "noticeable lag". On-device wins on physics, not just on principle.

If you opt into a remote transformer for the more complex pipelines (turning dictation into structured JSON for an API call), you accept that cost — but only for that specific hotkey. The transcription itself stays local always.

What it looks like

This is the menu bar app — you'd see it sitting in the top right of the screen, ready to receive your hotkeys:

The SpeechButton menu bar popover: status Ready, hotkey set to Right Command, toggles for Voice Activity Detection, Auto Send and Stream Chunks (PTT), a chunk-silence slider at 1.0s, model Parakeet V3 and the system default microphone.
The menu bar popover. Screenshot from SpeechButton 1.3.2; the current release is 2.13.5 and the panel has changed since.

Status indicator. Currently bound hotkey. Hands-free toggle. Model selector. Config panel.

The whole thing is ~2.5MB of Rust binary doing a job that, in 2026, every AI-agent-using developer probably needs.

What I gave up to make this work

Honest tradeoffs:

These tradeoffs were fine for me because the upside was: I stopped typing as the bottleneck. After two years of escalating "I should write more agents" energy, the limiting reagent stopped being the agents and started being my own input.

That's worth a config file.

Try it

The tool is SpeechButton. macOS 14+, Apple Silicon. Free for the first 5 minutes of transcription per day. Pro tier ($7.99/month, $69.99/year, or $129.99 one-time — Lifetime, up to 3 Macs) removes the limit.

$ brew install --cask speechbutton/tap/speechbutton

Or visit speechbutton.com — there's a download button on the home page and a one-page setup guide.

If you have a multi-agent setup similar to mine and want to compare notes, I'm @SpeechButton on X. Genuinely interested in how other people are routing their work across agent fleets — there's a lot of design space and not enough public discussion of it.


Next post in this series: how I built the tool itself — Apple Neural Engine, Rust over Swift, and the 20ms cold-start budget.

SpeechButton
I built SpeechButton — a 20ms push-to-talk voice layer for Mac. Try it if you want: speechbutton.com.