Output Routing
Routing lets you send transcriptions to different destinations based on pattern matching. Instead of always typing text, you can route voice commands to execute scripts, send webhooks, append to files, or copy to clipboard.
Basic Setup
Add a [routing] section to your config (~/.config/whislin/config.toml):
[routing]
enabled = true
[[routing.routes]]
name = "Quick Note"
pattern = "note "
pattern_type = "prefix"
strip_trigger = true
sink = "file"
file_path = "~/notes/voice-notes.md"
file_template = "- [{timestamp}] {text}\n"
Now saying "note remember to buy milk" will append - [2024-01-15T10:30:00Z] remember to buy milk to your notes file instead of typing it.
How Routing Works
- After transcription, routes are evaluated top to bottom
- The first matching route handles the text (unless
continue_after_match = true) - If no route matches, text is typed normally (default behavior)
Route Options
Every route supports these options:
| Option | Type | Default | Description |
|---|---|---|---|
name | string | required | Display name for the route |
pattern | string | required | Pattern to match against |
pattern_type | string | "prefix" | How to match: prefix, contains, exact, regex |
strip_trigger | bool | false | Remove matched pattern from payload |
sink | string | required | Destination: command, webhook, file, clipboard |
continue_after_match | bool | false | Keep evaluating more routes after this one |
require_confirmation | bool | false | Show confirmation dialog before executing |
Pattern Types
Prefix (default)
Matches if transcription starts with the pattern (case-insensitive).
[[routing.routes]]
name = "Note"
pattern = "note "
pattern_type = "prefix"
# Matches: "note buy groceries", "Note call mom"
# Doesn't match: "take a note", "notebook"
Contains
Matches if pattern appears anywhere in transcription.
[[routing.routes]]
name = "Urgent"
pattern = "urgent"
pattern_type = "contains"
# Matches: "this is urgent", "urgent task", "mark as urgent please"
Exact
Matches only if transcription equals the pattern exactly (case-insensitive).
[[routing.routes]]
name = "Stop"
pattern = "stop recording"
pattern_type = "exact"
# Matches: "stop recording", "STOP RECORDING"
# Doesn't match: "stop recording now", "please stop recording"
Regex
Full regular expression matching. Use for complex patterns.
[[routing.routes]]
name = "Email"
pattern = "^(email|mail|send)\\s+"
pattern_type = "regex"
# Matches: "email john about meeting", "mail the report", "send update"
Note: In TOML, backslashes must be escaped (
\\snot\s), or use single quotes.
Sink Types
Command Sink
Execute a binary with the transcription as input. Safe by design - you specify the exact binary, and transcription is passed as arguments (never interpreted as shell commands).
[[routing.routes]]
name = "Run Script"
pattern = "run "
strip_trigger = true
sink = "command"
command_binary = "/home/user/scripts/voice-handler.sh"
command_args = ["--text", "{text}"]
pass_via = "args" # or "stdin" or "env"
require_confirmation = true
Pass methods:
args(default) - Text passed as command argumentsstdin- Text written to process stdinenv- Text available asWHISLIN_TEXT,WHISLIN_RAW,WHISLIN_TIMESTAMPenvironment variables
Webhook Sink
Send HTTP requests to external services.
[[routing.routes]]
name = "Slack Message"
pattern = "slack "
strip_trigger = true
sink = "webhook"
webhook_url = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
webhook_method = "POST"
webhook_body = '{"text": "{text}"}'
webhook_headers = { "Content-Type" = "application/json" }
require_confirmation = true
Supported methods: GET, POST, PUT, PATCH, DELETE
File Sink
Append transcriptions to a file. Great for quick notes, journaling, or logging.
[[routing.routes]]
name = "Journal"
pattern = "journal "
strip_trigger = true
sink = "file"
file_path = "~/Documents/journal.md"
file_template = "## {timestamp}\n\n{text}\n\n"
~is expanded to your home directory- Parent directories are created automatically
- Uses append mode (won't overwrite existing content)
Clipboard Sink
Copy transcription to clipboard without typing it.
[[routing.routes]]
name = "Copy"
pattern = "copy "
strip_trigger = true
sink = "clipboard"
Uses xclip, xsel, or wl-copy depending on your system.
Template Variables
All sinks support these variables in templates:
| Variable | Description | Example |
|---|---|---|
{text} | Transcription (trigger stripped if configured) | buy groceries |
{raw} | Original transcription (trigger preserved) | note buy groceries |
{timestamp} | ISO 8601 timestamp | 2024-01-15T10:30:00.123Z |
{timestamp_unix} | Unix epoch seconds | 1705315800 |
Examples
Voice-controlled note-taking
[[routing.routes]]
name = "Quick Note"
pattern = "note "
pattern_type = "prefix"
strip_trigger = true
sink = "file"
file_path = "~/notes/inbox.md"
file_template = "- [ ] {text}\n"
Say: "note buy milk" → Appends - [ ] buy milk to inbox.md
Slack integration
[[routing.routes]]
name = "Slack Status"
pattern = "slack status "
strip_trigger = true
sink = "webhook"
webhook_url = "https://slack.com/api/users.profile.set"
webhook_method = "POST"
webhook_body = '{"profile": {"status_text": "{text}", "status_emoji": ":speech_balloon:"}}'
webhook_headers = { "Authorization" = "Bearer xoxp-your-token", "Content-Type" = "application/json" }
Custom script with confirmation
[[routing.routes]]
name = "AI Summary"
pattern = "summarize "
strip_trigger = true
sink = "command"
command_binary = "/home/user/scripts/ai-summarize.sh"
command_args = ["{text}"]
pass_via = "args"
require_confirmation = true
Multiple destinations
# Log everything to a file, then continue to other routes
[[routing.routes]]
name = "Log All"
pattern = ""
pattern_type = "prefix"
sink = "file"
file_path = "~/logs/voice.log"
file_template = "[{timestamp}] {raw}\n"
continue_after_match = true
# Then check for specific commands
[[routing.routes]]
name = "Todo"
pattern = "todo "
strip_trigger = true
sink = "file"
file_path = "~/notes/todo.md"
file_template = "- [ ] {text}\n"
Home Assistant integration
[[routing.routes]]
name = "Lights"
pattern = "lights "
strip_trigger = true
sink = "webhook"
webhook_url = "http://homeassistant.local:8123/api/services/light/turn_on"
webhook_method = "POST"
webhook_body = '{"entity_id": "light.living_room"}'
webhook_headers = { "Authorization" = "Bearer YOUR_HA_TOKEN", "Content-Type" = "application/json" }
Obsidian daily notes
[[routing.routes]]
name = "Daily Note"
pattern = "daily "
strip_trigger = true
sink = "command"
command_binary = "/bin/sh"
command_args = ["-c", "echo '- {text}' >> \"$HOME/Obsidian/Daily/$(date +%Y-%m-%d).md\""]
pass_via = "args"
Security Considerations
- Command sink: Only executes the specified binary - transcription is never passed to a shell interpreter directly. This prevents command injection attacks.
- Confirmation dialogs: Enable
require_confirmation = truefor routes that perform irreversible actions or call external services. - Webhook sink: Be careful with authentication tokens in config files. Set proper file permissions:
chmod 600 ~/.config/whislin/config.toml - File sink: Paths are validated, but be mindful of where you write (avoid system directories).
Troubleshooting
Route not matching
- Check
pattern_type- default isprefix, notcontains - Patterns are case-insensitive
- Add a space after trigger words (e.g.,
"note "not"note") to avoid matching "notebook"
Command not executing
- Verify the binary path exists and is executable
- Check logs:
journalctl --user -u whislin -for~/.local/share/whislin/logs/ - Try with
require_confirmation = trueto see what would be executed
Webhook failing
- Test the URL with curl first
- Check that
webhook_headersincludesContent-Type - Look for HTTP errors in logs