Skip to content

Script Filters

A script filter runs a command of yours when you type its keyword, passes it what you type after the keyword, and lists the items it prints. The command prints JSON in a subset of the Alfred Script Filter format, so any language works, and Raffi adds a few extensions of its own: tree-style indentation, informational rows and rich previews. Use it to bring in APIs, bookmarks, databases or any script that can print a list.

Script filter timezone converter example

This filter uses batz to convert times between timezones:

addons:
script_filters:
- name: "Timezones"
keyword: "tz"
command: "batz"
args: ["-j"]
icon: "clock"

Type tz followed by a space, then a place, such as tz new york. Raffi runs batz -j "new york" and lists the items it prints. Press Enter to copy the selected item.

Until you type the space after the keyword, Raffi keeps searching the main list and shows a row describing the filter; Enter or Tab on that row enters it. The command runs again each time the text after the keyword changes.

A filter that calls a slow API, such as the GitHub pull request script, can opt into a private stale-while-revalidate cache. Set cache_seconds to show the last result for a query immediately while a fresh run happens in the background. Without it, nothing is cached, so a script with side effects never runs from the cache.

addons:
script_filters:
- name: "Pull Requests"
keyword: "pr"
command: "~/.config/raffi/scripts/github/pr.py"
icon: "github"
cache_seconds: 300
warm_queries: [""]
warm_interval_seconds: 300

warm_queries are run periodically by raffi --cache-daemon, in every view the script offers, so these queries are already fresh when Raffi opens. warm_interval_seconds defaults to cache_seconds. Pressing Ctrl+R runs the script again with RAFFI_REFRESH=1 in its environment, which the bundled GitHub script uses to bypass the gh CLI cache and fetch live state. When the cache service is running, F5 asks it to do the same refresh and Raffi reads the new snapshot it writes.

Cached item lists and dynamic previews live under $XDG_CACHE_HOME/raffi/script-filters/. raffi --refresh-cache removes them. The cache keys include the command, arguments, environment and query, so changing the configuration starts from a clean entry.

KeyAction
EnterOpen an item’s children, or run action on a leaf item
Ctrl+EnterRun secondary_action, or action when it is not set
Up / DownMove through the results
EscapeReturn to the parent list, or close Raffi at the top level

Actions use the item’s arg, or its title when it has no arg. An action is one of:

  • copy, the default, to copy the value with wl-copy, xclip or xsel
  • insert, to close Raffi and type the value into the focused app with wtype or ydotool
  • any other text, run with sh -c after replacing {value} with the value

An item with children opens those items when you press Enter or click it. Type to filter the current child list by title, subtitle or arg; Raffi does not rerun the script while filtering inside it. Press Escape to return to the parent list with its query and selection restored. Children can themselves have children.

The value is also exported as $RAFFI_VALUE, and so are the item’s variables, so a script can pass structured data to its actions:

addons:
script_filters:
- name: "Bookmarks"
keyword: "bm"
command: "my-bookmark-script"
args: ["--json"]
icon: "bookmark"
action: "copy" # Enter: copy the URL
secondary_action: "xdg-open {value}" # Ctrl+Enter: open it

{value} is pasted into the command as raw text, so quotes or $(...) in it are read by the shell. Prefer "$RAFFI_VALUE" when the value comes from somewhere you do not control.

Contact fields with the first field selected and the child search cleared

The scripts/google-contacts/contacts.py filter lists saved contacts, searches their details, and shows contact information and photos in the preview. Enter a contact to see its fields; Enter a field to copy it. Escape returns to the previous list. Install it:

Terminal window
mkdir -p ~/.config/raffi/scripts/google-contacts
cp scripts/google-contacts/contacts.py ~/.config/raffi/scripts/google-contacts/contacts.py
chmod +x ~/.config/raffi/scripts/google-contacts/contacts.py

Enable People API in a Google Cloud project and create an OAuth client of type Desktop app. Store the downloaded client JSON in pass (default entry google/raffi-contacts), then run ~/.config/raffi/scripts/google-contacts/contacts.py --authorize --pass-entry google/raffi-contacts. The command opens browser consent and writes the refresh token back to that entry. Only the read-only contacts permission is requested. Set GOOGLE_CONTACTS_PASS_ENTRY in the filter env to use another entry. Alternatively, set args: ["--pass-entry", "google/agenda-credentials"]; use that same --pass-entry option for direct script runs.

addons:
script_filters:
- name: "Google Contacts"
keyword: "contacts"
command: "~/.config/raffi/scripts/google-contacts/contacts.py"
icon: "contact-new"
action: "copy"
close_on_action: false
dynamic_preview: false

Contact data and downloaded photos are cached separately for each pass entry under $XDG_CACHE_HOME/raffi/google-contacts/<entry-hash>/. Contact data is reused for five minutes. Ctrl+R refreshes from Google; a temporary API failure keeps showing that entry’s cached list. Missing photos download in the background without delaying results. Each worker stops after 30 seconds; later runs retry any remaining photos. Empty, oversized, or detectably incomplete responses are not cached. Failure to start the optional worker does not prevent contact results. Run the filter again to pick up newly cached photos.

Script filter items may include a children list of ordinary items. Selecting a valid parent opens its children with an empty search, and Escape restores its parent list, query, and selection. Typing searches the current children locally; selecting a leaf uses the filter’s configured action. Ctrl+R leaves child navigation and refreshes the root list using its original query. Switching filters or views also leaves child navigation.

GitHub PR browser

The scripts/github/pr.py script lists open pull requests with their CI status, review decision, conflicts, unresolved threads and size, using the GitHub CLI. Install it:

Terminal window
mkdir -p ~/.config/raffi/scripts/github
cp scripts/github/pr.py ~/.config/raffi/scripts/github/pr.py
chmod +x ~/.config/raffi/scripts/github/pr.py
addons:
script_filters:
- name: "Pull Requests"
keyword: "pr"
command: "~/.config/raffi/scripts/github/pr.py"
icon: "github"
action: "xdg-open {value}"
secondary_action: "copy"
pr your open pull requests across all repositories
pr review pull requests waiting for your review (also: assigned, involved)
pr owner/repo login open pull requests of owner/repo matching "login"

Ctrl+Tab switches between your pull requests, review requests, assigned and involved without retyping the query.

Enter opens the pull request in the browser and Ctrl+Enter copies its URL. When results span several owners (organizations or user accounts), they are grouped under owner headings that cannot be selected, with the pull requests indented beneath. Results from a single owner, or from a fixed owner/repo query, stay flat.

Add args: ["--repo", "owner/repo"] to give a repository its own keyword, and ALIAS_<name> variables in env for short repository names. Each row exports PR_URL, PR_REPO, PR_NUMBER and PR_BRANCH to the actions.

Each pull request shows the avatar of its repository owner, or of its author when every listed pull request comes from the same repository, cached under ~/.cache/raffi/github-avatars/. With the preview pane open, the selected pull request shows its branches, each reviewer’s verdict, merge state, unresolved threads, checks, the issues it closes, its description with the markdown rendered in colour, the latest comments from people and the changed files. It refreshes every 20 seconds while a check is still running. See the script’s README for all options.

The scripts/search/search.py script shows live DuckDuckGo autocomplete suggestions and opens the result in the search engine of your choice: google, duckduckgo, bing or brave. Install it:

Terminal window
mkdir -p ~/.config/raffi/scripts/search
cp scripts/search/search.py ~/.config/raffi/scripts/search/search.py
chmod +x ~/.config/raffi/scripts/search/search.py

Then add one entry per engine:

addons:
script_filters:
- name: "Search DuckDuckGo"
keyword: "ddg"
command: "~/.config/raffi/scripts/search/search.py"
args: ["duckduckgo"]
icon: "web-browser"
action: "xdg-open {value}"
secondary_action: "copy"
- name: "Search Google"
keyword: "sg"
command: "~/.config/raffi/scripts/search/search.py"
args: ["google"]
icon: "google"
action: "xdg-open {value}"
secondary_action: "copy"

Typing ddg rust async lists the query itself first, which opens a DuckDuckGo search, then the autocomplete suggestions. Enter opens the selected one in the browser and Ctrl+Enter copies its URL. With the preview pane open, a suggestion shows its DuckDuckGo Instant Answer (summary, source link and image) when there is one. The suggestions need no API key; on a timeout or network error the script only shows the query.

The web_searches addon opens a URL directly, without suggestions. Use it for instant access and this script when you want live suggestions.

The Herdr filter script browses a Herdr session as an indented tree of workspaces, tabs and panes. A tab with a single pane, shells included, appears once as the tab row and keeps its terminal preview; tabs with several panes list them underneath. The query matches tab and pane details and keeps the ancestors of each match. Select a workspace or a tab to focus it, or an agent pane to focus the agent.

Herdr demo workspace tree with connected session and pane rows and the selected split session's details

Install the script and its icons, and copy the icon directory again when upgrading, license notices included:

Terminal window
mkdir -p ~/.config/raffi/scripts/herdr
cp scripts/herdr/herdr_filter.py ~/.config/raffi/scripts/herdr/herdr_filter.py
cp -R scripts/herdr/icons ~/.config/raffi/scripts/herdr/
chmod +x ~/.config/raffi/scripts/herdr/herdr_filter.py

Every item exports HERDR_KIND (workspace, tab, agent or pane) and HERDR_FOCUS, the id to focus, so one action handles every kind:

addons:
script_filters:
- name: "Herdr"
keyword: "herdr"
command: "~/.config/raffi/scripts/herdr/herdr_filter.py"
icon: "utilities-terminal"
env:
HERDR_ICON_SET: color
action: >-
case "$HERDR_KIND" in
workspace) herdr workspace focus "$HERDR_FOCUS" ;;
tab) herdr tab focus "$HERDR_FOCUS" ;;
agent) herdr agent focus "$HERDR_FOCUS" ;;
*) herdr tab focus "$HERDR_FOCUS" ;;
esac

The default color icons tell workspaces, sessions, shells and agents apart, and preview headers use them too. Set HERDR_ICON_SET to mono-dark (pale marks for dark themes) or mono-light (dark marks for light themes) for monochrome icons. No icon font is needed.

Herdr demo agents grouped under Working, Waiting and Idle, with terminal output for the selected agent

For an agent status tree, set HERDR_VIEW: status in env or add args: ["--status"]. Agents from every workspace are grouped under Working, Waiting (Herdr’s blocked), Idle, Done and Unknown, and empty groups are hidden. Each agent appears once, agents in split tabs included, with its workspace in the subtitle. Plain shells stay in the workspace view. Group headings show their counts and a summary preview but have no action, while agent rows keep their focus action and terminal preview. Search waiting or blocked to find the agents that need you. The workspace tree stays the default (HERDR_VIEW: workspace or --tree). Whichever one you configure, Ctrl+Tab switches between Workspaces, Status and Agents.

For a flat list of recognised agents only, add args: ["--agents"] and focus them directly:

addons:
script_filters:
- name: "Herdr Agents"
keyword: "hagents"
command: "~/.config/raffi/scripts/herdr/herdr_filter.py"
args: ["--agents"]
icon: "utilities-terminal"
action: 'herdr agent focus "$HERDR_FOCUS"'

To raise the terminal window before focusing, start the action with the line for your compositor, changing kitty to your terminal’s class or app id:

Terminal window
# Hyprland
hyprctl dispatch focuswindow class:kitty;
# Sway
swaymsg '[app_id="kitty"] focus';
# niri (needs jq)
id=$(niri msg -j windows | jq -r '.[] | select(.app_id == "kitty") | .id' | head -n1);
[ -z "$id" ] || niri msg action focus-window --id "$id";

For example, on Hyprland:

action: >-
hyprctl dispatch focuswindow class:kitty;
case "$HERDR_KIND" in
workspace) herdr workspace focus "$HERDR_FOCUS" ;;
tab) herdr tab focus "$HERDR_FOCUS" ;;
agent) herdr agent focus "$HERDR_FOCUS" ;;
*) herdr tab focus "$HERDR_FOCUS" ;;
esac

With the preview pane open, the selected row shows a live preview refreshed every 2 seconds: agent panes show their status, directory and recent terminal output, tabs show their pane list (and the terminal of single-pane tabs), and workspaces show their tab list and active tab. See the script’s README for more.

A script filter listed in general.main_view as script_filters."<name>" also runs without its keyword: its first five items show in the main list each time you pause typing, once the query is at least min_query_length long. It can also be one of the fallbacks shown at the bottom of every search.

Script filters are a list under addons.script_filters, one entry per keyword.

name string (required)

Name shown while the command runs, and used to refer to the filter in main_view, fallbacks and -o addons.script_filters.<name>.<key>=....

keyword string (required)

Text that enters the filter. Type it followed by a space: tz enters the filter while tz keeps searching the main list. When sources share a keyword, the first script filter with it wins, and script filters come before text snippets, web searches and the other addons.

command string (required)

Program to run. It is started directly, without a shell, with args and then the text typed after the keyword as its last argument (empty when nothing is typed). A leading ~/ and ${VAR} references are expanded.

args list of strings (default: [])

Arguments passed before the query. They are passed as written, so use a full path rather than ~.

icon string

Icon name or image path for items without an icon of their own. A leading ~/ and ${VAR} references are expanded.

action string (default: copy)

What Enter does with the selected item: copy, insert, or a shell command, as described in Actions. A leading ~/ and ${VAR} references are expanded when the config loads, so write $VAR without braces for the variables an item exports.

secondary_action string (default: same as action)

What Ctrl+Enter does. It takes the same values as action and is expanded the same way.

env map of strings (default: {})

Environment variables passed to the command, including its preview runs. They are not passed to actions.

min_query_length integer (default: 0)

Number of characters to type after the keyword before the command runs. Until then the list is empty and a row says how many more characters to type. Useful for scripts that are slow or return too much on short queries.

dynamic_preview boolean (default: true)

Run the command again for the selected item to fill the preview pane, as described in Dynamic previews. Set it to false to only use the items’ preview field.

close_on_action boolean (default: true)

Close Raffi after running an action. When unset, general.close_on_action applies. insert always closes Raffi first.

addons:
script_filters:
- name: "Timezones"
keyword: "tz"
command: "batz"
args: ["-j"]
icon: "clock"
action: "copy"
secondary_action: 'notify-send "$RAFFI_VALUE"'
env:
TZ_FORMAT: "short"
min_query_length: 2
dynamic_preview: false
close_on_action: false

The command prints a JSON object with an items list on its standard output. Anything it writes on standard error is ignored. When the command fails or prints something that is not valid JSON, the list stays empty, so try a script in a terminal first, such as my-script -j "test query".

A minimal script:

#!/usr/bin/env python3
import json
import sys
query = sys.argv[1] if len(sys.argv) > 1 else ""
print(json.dumps({"items": [
{
"title": f"Result for: {query}",
"subtitle": "This is a subtitle",
"arg": f"https://example.com/search?q={query}",
"icon": {"path": "/usr/share/icons/search.png"},
}
]}))
addons:
script_filters:
- name: "My Filter"
keyword: "mf"
command: "~/bin/my-filter.py"
action: "xdg-open {value}"

A fuller output:

{
"items": [
{
"title": "New York",
"subtitle": "EST (UTC-5), 14:30",
"arg": "America/New_York",
"icon": { "path": "/usr/share/icons/clock.png" }
},
{
"title": "London",
"subtitle": "GMT (UTC+0), 19:30",
"arg": "Europe/London"
}
]
}

Add a children list to make an item open a nested list instead of running an action. Each child uses the same item fields, including children for deeper levels:

{
"items": [
{
"title": "Alice",
"children": [
{ "title": "Email", "arg": "alice@example.com" },
{ "title": "Phone", "arg": "+1 555 0100" }
]
}
]
}

Press Enter on Alice to browse her contact methods. Enter on a child without children runs the configured action; Escape goes back one level.

Raffi lists the items in the order the script prints them. Filtering and sorting are the script’s job.

title string (required)

Main text of the row. ANSI colours are supported.

children list of items

Optional nested items. Enter or clicking the parent opens this list; the parent does not run an action. Child items use the same fields as top-level items.

subtitle string

Second line of the row, with ANSI colours too.

arg string

Value the actions use. The title is used when it is missing.

icon.path string

Absolute path to a PNG or SVG icon. Without it, the filter’s icon is used.

valid boolean (default: true)

Set it to false for informational rows or headings. They can be selected to read their preview, but Enter, Ctrl+Enter and clicks do nothing and do not close Raffi. These rows are not recorded as recent picks and do not count as results for --select-1.

indent integer (default: 0)

Raffi extension, ignored by Alfred: the tree level of the item. See Tree results.

preview string or object

What the preview pane shows for the item: multiline text with ANSI colours, a local file or image path, or a preview object. Add "dynamic": false to a preview object to keep that item out of dynamic previews.

variables map of strings

Alfred variables. Each one is exported as an environment variable to the preview run and to the action and secondary_action commands, so the script can pass data along without packing it into arg.

quicklookurl string

Alfred QuickLook URL or local file path (file://, /path or ~/path). Local files and images show in the preview pane.

text.largetype string

Alfred large-type text, shown in the preview pane when preview is not set.

text.copy string

Alfred copy text, shown in the preview pane.

Titles, subtitles and preview text understand ANSI escape codes: 16, 256 and 24-bit colours, plus bold, dim, italic and underline. The 16 basic colours come from the theme’s palette. Other escape sequences, such as cursor movement or terminal hyperlinks, are removed, and tabs are expanded to 8 columns, so output captured from tools like bat or eza displays cleanly.

{
"items": [
{
"title": "\u001b[32mSuccess\u001b[0m",
"subtitle": "\u001b[90mCompleted at 14:30\u001b[0m"
}
]
}

The GitHub pull requests script uses colours for the CI state of each pull request.

indent shifts a row further right for each level and draws connector guides, so a script can return a hierarchy, such as the Herdr workspace, tab and pane tree:

{
"items": [
{ "title": "raffi [working]", "subtitle": "1 tab · 2 panes", "arg": "w1", "indent": 0 },
{ "title": "Feature [working]", "subtitle": "copilot, shell", "arg": "w1:t1", "indent": 1 },
{ "title": "copilot [working] - raffi", "arg": "w1:p1", "indent": 2 },
{ "title": "shell - raffi", "arg": "w1:p2", "indent": 2 }
]
}

Print parents before their children and keep each subtree together. A row’s parent is the nearest row above it with a smaller indent; skipped levels do not add rows. A row without a parent keeps its offset but draws no branch. Offsets stop growing after ten levels, while deeper rows keep their relationships; connections that would overlap at that limit are left out. Guides only link rows that are shown, and stop at the edge of a source, including results cut short in the main list.

The script still does the filtering: it receives the query and can return only the matching rows plus their ancestors.

A script with several ways of listing its items, such as the GitHub review requests or the Herdr status tree, can offer them as views. Ctrl+Tab then switches to the next view and Ctrl+Shift+Tab to the previous one, keeping what you typed, and the footer shows the view you are on. Add a views list next to items:

{
"views": [
{ "id": "open", "title": "Open issues" },
{ "id": "all", "title": "All issues", "variables": { "SHOW_ALL": "1" } }
],
"view": "open",
"items": []
}

Each view has an id, a title for the footer (the id when missing) and optional variables, which are set on top of the filter’s env, overriding entries with the same name. Raffi first runs the command as usual, and view names the view the script opened on; without it, the first one in the list is assumed. After a switch, Raffi runs the command again with the view’s variables and RAFFI_VIEW set to its id in the environment, and preview runs get them too. Switching back to the view it opened on runs the command without RAFFI_VIEW again, and each time you enter the filter it starts there.

Every view is cached under its own key, and the cache service warms each warm_queries entry in every view. A script that prints no views works as before, and Raffi versions without views ignore the extra fields.

When the preview pane is open (Ctrl+P), Raffi runs the command again for the selected item and shows the preview object it prints. The command, args and query are the same as for the item list, with these environment variables added:

VariableValue
RAFFI_PREVIEW1
RAFFI_VALUEthe item’s arg, or its title
RAFFI_TITLEthe item’s title without ANSI codes
RAFFI_QUERYthe text typed after the keyword
RAFFI_PREVIEW_WIDTH, RAFFI_PREVIEW_HEIGHTpreview pane size in pixels
RAFFI_THEME, RAFFI_THEME_MODE, RAFFI_THEME_FG, …the current theme, see Terminal Colours

The filter’s env and the item’s variables are exported too. The run waits until you stop moving through the list, is killed after 3 seconds, and its output is capped at 8 MiB.

A script supports this by checking RAFFI_PREVIEW before building its list:

import json, os
if os.environ.get("RAFFI_PREVIEW") == "1":
value = os.environ["RAFFI_VALUE"]
print(json.dumps({"title": value, "text": f"Details for {value}"}))
raise SystemExit

Scripts that ignore RAFFI_PREVIEW keep working. Raffi waits for the first preview run of a filter to answer before starting another. Any JSON object without an items key is a preview object, so {} is an empty preview. If the first run fails, times out, or prints something other than a preview object (the usual {"items": [...]}, or text that is not JSON), Raffi stops asking that filter for the rest of the session and uses the items’ preview field.

Once a filter has printed a preview object, even one Raffi cannot read (such as a field with the wrong type), errors are shown instead: a failed run (non-zero exit or timeout) or an unreadable preview object keeps the last preview on screen, adds a Preview row with the error, and is retried the next time the item is selected.

Every field is optional:

{
"title": "claude [working]",
"subtitle": "project-a",
"badge": "Herdr",
"icon": "utilities-terminal",
"text": "\u001b[32mANSI text\u001b[0m",
"image": "/tmp/shot.png",
"sections": [
{ "type": "text", "heading": "Output", "content": "..." },
{ "type": "image", "base64": "iVBORw0KGgo..." },
{ "type": "file", "path": "~/notes.md" }
],
"metadata": [["Status", "working"], ["Directory", "/home/me/project-a"]],
"refresh": 2
}

title, subtitle and badge replace the header of the preview pane.

icon is an icon theme name, an image path or an emoji.

text is the body text, with ANSI colours (16, 256 and 24-bit).

image is a local image path, {"path": "..."} or {"base64": "..."}. Base64 data may also be a data:image/...;base64, URL. Use RAFFI_PREVIEW_WIDTH and RAFFI_PREVIEW_HEIGHT to size generated images.

sections is a list of body blocks, shown in order. A text block takes content and an optional heading, an image block takes path or base64, and a file block takes a path shown with the same viewer as the file browser (text, images, directories). When sections is set, text and image are ignored, and an empty list clears the body. Without it the image comes first, then the text. A preview with no sections, text or image keeps the item’s own preview body and only changes the header and metadata.

metadata is a list of [label, value] rows shown above Raffi’s own Enter and Ctrl+Enter rows.

refresh is the number of seconds after which Raffi asks again while the item stays selected, between 0.5 and 3600. Use it for live data such as logs or progress. A file section is read again on every refresh.

The same object can be an item’s static preview field, where refresh has no effect. Add "dynamic": false to it to keep that item out of the dynamic preview runs.