Post-process dictation with a Python script

Any program that reads text on stdin and writes text on stdout can rewrite what you dictate. Set up the shipped Slack mentions script, then write your own.

What you will get

Two scripts. slack_mentions ships with ParrotFlow and runs when you ask for it. today is one you write, and it runs on every dictation.

You say Script You get
tell Marie and Thomas the deadline moved slack_mentions tell @marie.dupont and @tleroy the deadline moved
status update for today’s date today status update for 2026-10-10

today writes the date of the day you dictate. The output above is from a test run on 10 October 2026.

How a script step works

A transform with a command: body runs a program. The text arrives on stdin. Whatever the program prints on stdout replaces it. That is the whole contract.

Use a script when a replace table cannot do the job: a lookup in your own data, a date, a change of case, anything with a branch. A Python script costs one process start, about 30 ms. For a fixed phrase or a pattern, a replace table is enough and costs nothing.

Where scripts live

A transform named X owns the folder ~/.config/parrotflow/transforms/X/.

  • A bare name in command: is looked for in that folder, and nowhere else. command: today.py on a transform named today runs transforms/today/today.py.
  • That folder is the working directory, so a script can open a data file beside it by its bare name.
  • The script is run directly. Its first line picks the interpreter, for example #!/usr/bin/env python3.
  • It needs the execute bit. Run chmod +x on it. The docs call a missing execute bit “the likeliest thing to be wrong” with a script step.
  • A bare name that is not a file in the folder, like tr or sed, is looked up on your PATH. So a one-line shell command works too.

transforms/built-in/ holds the scripts that ship. The app refreshes that folder on every launch, so do not edit files there. Copy a folder out of it into transforms/<name>/ first.

Set up the shipped Slack mentions script

  1. Open ~/.config/parrotflow/transforms/slack_mentions/slack_mentions.py. The app writes it once, on first launch. After that it is yours and the app never overwrites it.

  2. Fill the roster. One line per person: the name as you say it, then the handle.

    ROSTER = {
        "Marie": "@marie.dupont",
        "Thomas": "@tleroy",
    }

    A name matches as a whole word and only with that exact case, so “mark it as done” does not become a mention of Mark. A name already written as a handle is left alone. Never guess a handle. A wrong one pings the wrong person.

  3. Leave the config entry as it is. A new install already has it:

    transforms:
      - name: slack_mentions
        description: turn people's names into Slack mentions
        display: Slack Mentions
        offer: true
        key: s
        say: [slack mentions, mentions]
        command: slack_mentions.py

    It has no pipeline step, on purpose. A message that names someone is not always a message that should ping them. You run it when you want it:

    • offer: true and key: s put an S chip on the pill after each dictation. Press S or click the chip. It rewrites the sentence you just dictated.
    • say: lists words for the tap-then-hold gesture. Tap the hotkey, hold it again, and say “slack mentions”. This path uses no model.
    • description: is what “hey parrot, use Slack mentions” is matched against. That path asks a model to pick the transform, so it needs one in models:.

With an empty roster, the chip opens the script’s folder and the pill says open slack_mentions.py to set up Slack mentions. The text is not changed.

Check it

Run the script by hand first. It is a plain program:

cd ~/.config/parrotflow/transforms/slack_mentions
echo "tell Marie and Thomas the deadline moved" | ./slack_mentions.py
tell @marie.dupont and @tleroy the deadline moved

Then let ParrotFlow run it. Write a few cases in cases.yaml in the same folder. A case with no expect: must come back unchanged.

cases:
  - input:  tell Marie the deadline moved
    expect: tell @marie.dupont the deadline moved
  - input:  Thomas and Marie are both off on Friday
    expect: "@tleroy and @marie.dupont are both off on Friday"
  - input:  ask @marie.dupont about the invoice
  - input:  mark it as done
/Applications/ParrotFlow.app/Contents/MacOS/ParrotFlow --eval slack_mentions
  change   2/2 = 100%
  keep     2/2 = 100%   <- must come back byte for byte
  overall  4/4 = 100%

--eval runs the transform your config names, with the same code the app runs. Check that the spoken words reach it:

/Applications/ParrotFlow.app/Contents/MacOS/ParrotFlow --route "slack mentions" --keyed
→ slack_mentions  (named outright, no model)

Write your own script

This one replaces the words “today’s date” with the date in ISO format. A replace table cannot do it, because the output changes every day.

  1. Make the folder and the script:

    mkdir -p ~/.config/parrotflow/transforms/today

    Save this as ~/.config/parrotflow/transforms/today/today.py:

    #!/usr/bin/env python3
    import datetime
    import re
    import sys
    
    text = sys.stdin.read()
    today = datetime.date.today().isoformat()
    text = re.sub(r"\btoday's date\b", today, text, flags=re.IGNORECASE)
    sys.stdout.write(text)
  2. Make it executable:

    chmod +x ~/.config/parrotflow/transforms/today/today.py
  3. Add it under the transforms: key in config.yaml:

    transforms:
      - name: today
        description: write today's date
        command: today.py
  4. Add a step at the end of transcription.pipeline. Keep the steps that are already there. A transform with no step runs only when you ask for it.

    transcription:
      pipeline:
        # the steps already in your config stay here
        - transform: disfluency
        - transform: today
  5. Save. The app reloads the config at once.

Check it

/Applications/ParrotFlow.app/Contents/MacOS/ParrotFlow --check-config

--check-config names every transform that runs a program, every time. It also prints the full path it resolved:

  · transforms: "today" runs a program — today.py
      today           command  /Users/you/.config/parrotflow/transforms/today/today.py

Then run a sentence through the pipeline:

/Applications/ParrotFlow.app/Contents/MacOS/ParrotFlow --replace "status update for today's date"
status update for 2026-10-10

“we ship today” comes back unchanged, because it does not say “today’s date”.

Run it on demand instead

To run today only when you ask, leave out the pipeline step. Add any of these fields to the transform instead:

  - name: today
    description: write today's date
    offer: true        # a chip on the pill after each dictation
    key: d             # the letter on the chip
    say: [todays date] # words for tap-then-hold
    command: today.py

--check-config then lists the chip and its letter:

  · offer             3 transform(s) on the pill, after Vocabulary
      today — D
      grammar — G
      slack_mentions — S

--route "today's date" --keyed answers → today (named outright, no model). --replace "status update for today's date" now returns the sentence unchanged, because the step is gone.

A chip on the pill runs over the sentence you just dictated and writes the result back in place. The letter keys on the pill need the Input Monitoring permission. See Permissions.

When a script fails

Your words are never lost to a broken script. If it fails, the step keeps the text as it arrived and writes the reason to ~/Library/Logs/ParrotFlow.log. We tested three failures. The sentence came through each time, and these are the log lines:

What went wrong Log line
No execute bit command: …/transforms/today/today.py is not executable — chmod +x it; kept the transcript
The script exited with an error command: "broken.py" exited 1: something went wrong
It ran longer than timeout_seconds: 1 command: "slow.py" took longer than 1s; kept the transcript

--check-config also catches the missing execute bit before you dictate:

  ✗ transforms: "today" cannot run today.py — /Users/you/.config/parrotflow/transforms/today/today.py is not executable — chmod +x it

The docs list one more case: a script that prints nothing also leaves the text alone.

The timeout is 2 seconds unless you set timeout_seconds on the transform. It counts until the process exits. A script that goes over is stopped.

To read the log while you dictate:

tail -f ~/Library/Logs/ParrotFlow.log

Limits

  • A command: makes config.yaml run code. Read any script before you add it, and do not paste a config from a source you do not trust.
  • Plain text in, plain text out. The script cannot see which app you dictate into. For that, use returns: json, described in Writing a transform.
  • ParrotFlow pastes the text. The docs say it is not yet confirmed that a pasted @handle turns into a real mention in Slack. Paste one into a Slack message without sending it and check.
  • --replace runs your pipeline steps. It does not run a transform that has no step. Use --eval for those.