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.
Quick start
Section titled “Quick start”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.
Caching slow filters
Section titled “Caching slow filters”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: 300warm_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.
Actions
Section titled “Actions”| Key | Action |
|---|---|
| Enter | Open an item’s children, or run action on a leaf item |
| Ctrl+Enter | Run secondary_action, or action when it is not set |
| Up / Down | Move through the results |
| Escape | Return 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 withwl-copy,xcliporxselinsert, to close Raffi and type the value into the focused app withwtypeorydotool- any other text, run with
sh -cafter 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.
Examples
Section titled “Examples”Google Contacts
Section titled “Google Contacts”
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:
mkdir -p ~/.config/raffi/scripts/google-contactscp scripts/google-contacts/contacts.py ~/.config/raffi/scripts/google-contacts/contacts.pychmod +x ~/.config/raffi/scripts/google-contacts/contacts.pyEnable 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: falseContact 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 pull requests
Section titled “GitHub pull requests”
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:
mkdir -p ~/.config/raffi/scripts/githubcp scripts/github/pr.py ~/.config/raffi/scripts/github/pr.pychmod +x ~/.config/raffi/scripts/github/pr.pyaddons: 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 repositoriespr 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.
Web search with autocomplete
Section titled “Web search with autocomplete”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:
mkdir -p ~/.config/raffi/scripts/searchcp scripts/search/search.py ~/.config/raffi/scripts/search/search.pychmod +x ~/.config/raffi/scripts/search/search.pyThen 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.
Herdr sessions
Section titled “Herdr sessions”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.
Install the script and its icons, and copy the icon directory again when upgrading, license notices included:
mkdir -p ~/.config/raffi/scripts/herdrcp scripts/herdr/herdr_filter.py ~/.config/raffi/scripts/herdr/herdr_filter.pycp -R scripts/herdr/icons ~/.config/raffi/scripts/herdr/chmod +x ~/.config/raffi/scripts/herdr/herdr_filter.pyEvery 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" ;; esacThe 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.
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:
# Hyprlandhyprctl dispatch focuswindow class:kitty;# Swayswaymsg '[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" ;; esacWith 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.
In the main list
Section titled “In the main list”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.
Configuration
Section titled “Configuration”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: falseOutput format
Section titled “Output format”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 python3import jsonimport 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.
Item fields
Section titled “Item fields”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.
Colours
Section titled “Colours”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.
Tree results
Section titled “Tree results”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.
Dynamic previews
Section titled “Dynamic previews”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:
| Variable | Value |
|---|---|
RAFFI_PREVIEW | 1 |
RAFFI_VALUE | the item’s arg, or its title |
RAFFI_TITLE | the item’s title without ANSI codes |
RAFFI_QUERY | the text typed after the keyword |
RAFFI_PREVIEW_WIDTH, RAFFI_PREVIEW_HEIGHT | preview 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 SystemExitScripts 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.
Preview object
Section titled “Preview object”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.