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

  1. After transcription, routes are evaluated top to bottom
  2. The first matching route handles the text (unless continue_after_match = true)
  3. If no route matches, text is typed normally (default behavior)

Route Options

Every route supports these options:

OptionTypeDefaultDescription
namestringrequiredDisplay name for the route
patternstringrequiredPattern to match against
pattern_typestring"prefix"How to match: prefix, contains, exact, regex
strip_triggerboolfalseRemove matched pattern from payload
sinkstringrequiredDestination: command, webhook, file, clipboard
continue_after_matchboolfalseKeep evaluating more routes after this one
require_confirmationboolfalseShow 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 (\\s not \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 arguments
  • stdin - Text written to process stdin
  • env - Text available as WHISLIN_TEXT, WHISLIN_RAW, WHISLIN_TIMESTAMP environment 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:

VariableDescriptionExample
{text}Transcription (trigger stripped if configured)buy groceries
{raw}Original transcription (trigger preserved)note buy groceries
{timestamp}ISO 8601 timestamp2024-01-15T10:30:00.123Z
{timestamp_unix}Unix epoch seconds1705315800

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 = true for 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 is prefix, not contains
  • 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 -f or ~/.local/share/whislin/logs/
  • Try with require_confirmation = true to see what would be executed

Webhook failing

  • Test the URL with curl first
  • Check that webhook_headers includes Content-Type
  • Look for HTTP errors in logs