| name | afwf |
| description | Guide for building Alfred workflow Python packages using the afwf SDK. Use when creating, modifying, or debugging afwf-based Alfred workflows — Script Filters, CLI entry points, fuzzy matching, disk caching, write actions, testing, or deployment. |
afwf — Alfred Workflow Python SDK
afwf is a Python SDK for building Alfred Script Filter workflows. The core idea: write Python functions that return a ScriptFilter object, expose them as a CLI via fire, call the CLI from Alfred's Script field. Do NOT search the source repo — this skill is the complete reference.
Architecture: Three Layers
Layer 1 — Python logic (my_pkg/your_module.py)
Your package name is whatever you choose (e.g. my_pkg, my_workflow_pkg).
afwf is the SDK you import — not your package name.
Pure functions: main(query: str) → ScriptFilter. No Alfred dependency. Unit-testable.
Layer 2 — CLI entry point (my_pkg/cli.py → declared as console_script)
fire.Fire(Command) exposes each main() as a subcommand.
Alfred's Script field calls this binary.
Layer 3 — Alfred workflow config (info.plist)
Keywords, Script Filter nodes, Conditional branches, action widgets.
Static — never changes when Python logic changes.
pyproject.toml declares the entry point:
[project.scripts]
my-workflow = "my_pkg.cli:main"
my_pkg/cli.py:
import fire
from my_pkg import search_items, write_item
class Command:
def search_items(self, query: str = ""):
search_items.main(query=query).send_feedback()
def write_item_request(self, content: str):
write_item.write_request(content)
def main():
fire.Fire(Command)
Alfred Script field (dev): .venv/bin/my-workflow search-items --query '{query}'
Alfred Script field (prod): ~/.local/bin/uvx --from my-pkg==1.0.0 my-workflow search-items --query '{query}'
Complete Import Map
Everything from afwf comes through afwf.api. Never import from sub-modules directly.
import afwf.api as afwf
Optional extras have their own import paths (NOT through afwf.api):
import afwf.opt.fuzzy_item.api as fuzzy_item
from afwf.opt.fuzzy.api import FuzzyMatcher
from afwf.opt.cache.api import TypedCache
Core Classes
ScriptFilter
import afwf.api as afwf
sf = afwf.ScriptFilter()
sf.items.append(item)
sf.send_feedback()
Fields: items: list[Item]
Item
item = afwf.Item(
title="My Result",
subtitle="Details here",
arg=None,
autocomplete=None,
match=None,
valid=True,
uid=None,
type=None,
icon=None,
text=None,
quicklookurl=None,
mods=None,
variables={},
)
Item methods (all return self for chaining):
set_icon(path: str) — set icon from image file path
set_modifier(mod, subtitle, arg, valid) — add modifier key override
open_url(url: str) — set variables for Open URL action
open_file(path: str) — set variables for Open File action
launch_app_or_file(path: str) — set variables for Launch App/File action
reveal_file_in_finder(path: str) — set variables for Reveal in Finder action
browse_in_terminal(path: str) — set variables for Browse in Terminal action
browse_in_alfred(path: str) — set variables for Browse in Alfred action
run_script(cmd: str) — set variables + item.arg for Run Script action
terminal_command(cmd: str) — set variables + item.arg for Terminal Command action
send_notification(title: str, subtitle: str = "") — set variables for Post Notification
Icon
item.icon = afwf.Icon(path=afwf.IconFileEnum.error)
item.icon = afwf.Icon(path="/absolute/path/to/icon.png")
item.icon = afwf.Icon(path="~/Desktop", type="fileicon")
item.icon = afwf.Icon(path="com.apple.rtfd", type="filetype")
Fields: path: str, type: str | None (None = treat path as image file)
Text
item.text = afwf.Text(
copy_text="text copied with ⌘C",
largetype="text shown with ⌘L",
)
Fields: copy_text: str | None (alias "copy"), largetype: str | None
IconFileEnum — Complete List of Bundled Icons
All values are absolute paths to PNG files bundled with afwf:
from afwf.api import IconFileEnum
IconFileEnum.error
IconFileEnum.info
IconFileEnum.question
IconFileEnum.search
IconFileEnum.check
IconFileEnum.close
IconFileEnum.gear
IconFileEnum.refresh
IconFileEnum.star
IconFileEnum.bookmark
IconFileEnum.folder
IconFileEnum.file
IconFileEnum.archive_folder
IconFileEnum.desktop
IconFileEnum.download
IconFileEnum.upload
IconFileEnum.trash
IconFileEnum.plus
IconFileEnum.minus
IconFileEnum.remove
IconFileEnum.redo
IconFileEnum.undo
IconFileEnum.reset
IconFileEnum.start
IconFileEnum.stop
IconFileEnum.pause
IconFileEnum.rocket
IconFileEnum.code
IconFileEnum.console
IconFileEnum.bash
IconFileEnum.debug
IconFileEnum.git
IconFileEnum.flask
IconFileEnum.chat
IconFileEnum.groupchat
IconFileEnum.mail
IconFileEnum.message
IconFileEnum.landline
IconFileEnum.meeting
IconFileEnum.laptop
IconFileEnum.desktop
IconFileEnum.iphone
IconFileEnum.android
IconFileEnum.calendar
IconFileEnum.id_card
IconFileEnum.idea
IconFileEnum.internet
IconFileEnum.password
IconFileEnum.box
IconFileEnum.fire
IconFileEnum.explosion
IconFileEnum.task
IconFileEnum.todo
IconFileEnum.dictionary
IconFileEnum.microsoft_excel
IconFileEnum.microsoft_powerpoint
IconFileEnum.microsoft_word
Serialization Rules
item.to_script_filter() is called internally by send_feedback(). Rules differ from plain model_dump():
| Python value | Output |
|---|
None | Field omitted — Alfred uses its default |
False / 0 / "" | Preserved — e.g. valid=False must appear in JSON |
Nested ScriptFilterObject that serializes to {} | Field omitted |
Top-level dict that is {} | Field omitted (e.g. empty variables) |
dict nested inside another dict (e.g. in mods) | Passed through unchanged |
list (any length, including []) | Always preserved — items: [] is required |
You never call to_script_filter() directly. Just build objects and call send_feedback().
Item Actions — Conditional Widget Pattern
Each item.set_* / item.action_* helper writes a flag variable ("y") + payload variable. Alfred's Conditional widget reads the flag and routes to the correct downstream widget. Set up the Conditional widget once in Alfred's UI; it covers all Script Filters.
item.open_url("https://example.com")
item.open_file(path="/path/to/file.py")
item.launch_app_or_file(path="/Applications/Safari.app")
item.reveal_file_in_finder(path="/path/to/file")
item.run_script("/path/to/bin/my-workflow write-file-request --content 'hello'")
item.terminal_command("echo hello")
item.send_notification(title="Done", subtitle="File written")
Alfred Conditional widget config (one-time setup in Alfred UI):
if {var:open_url} = y → Open URL widget URL: {var:open_url_arg}
if {var:open_file} = y → Open File widget File: {var:open_file_path}
if {var:run_script} = y → Run Script widget Script: {query}
if {var:send_notification} = y → Post Notification Title: {var:send_notification_title}
Subtitle: {var:send_notification_subtitle}
Combining actions — e.g. run_script + send_notification:
item.run_script(cmd)
item.send_notification(title="Done", subtitle="success")
sys.executable trick — Alfred's sandboxed shell has no $PATH, so always use absolute binary path:
import sys
from pathlib import Path
bin_cli = Path(sys.executable).parent / "my-workflow"
cmd = f"{bin_cli} write-file-request --content {content!r}"
item.run_script(cmd)
Modifier key overrides — hold ⌘ to show different subtitle/arg:
import afwf.api as afwf
item.set_modifier(
mod=afwf.ModEnum.cmd,
subtitle="Press ⌘ to open folder instead",
arg="/different/arg",
valid=True,
)
QueryParser — Multi-Step Interactions
Alfred passes one raw query string. Use Query to branch on token count:
import afwf.api as afwf
q = afwf.Query.from_str("username alice")
q.parts
q.trimmed_parts
q.n_trimmed_parts
parser = afwf.QueryParser.from_delimiter("/")
q = parser.parse("2026/04/08")
q.trimmed_parts
parser = afwf.QueryParser.from_delimiter([" ", ","])
Standard two-step pattern (pick key → enter value):
@afwf.log_error()
def main(query: str) -> afwf.ScriptFilter:
q = afwf.Query.from_str(query)
if q.n_trimmed_parts == 0:
return build_all_items()
elif q.n_trimmed_parts == 1:
return fuzzy_filter(q.trimmed_parts[0])
else:
key = q.trimmed_parts[0]
value = " ".join(q.trimmed_parts[1:])
return build_confirmation(key, value)
log_error Decorator
Alfred silently swallows Python exceptions — nothing shows in the UI. log_error writes tracebacks to a rotating log file on disk:
import afwf.api as afwf
@afwf.log_error()
def main(query: str) -> afwf.ScriptFilter:
...
@afwf.log_error(log_file="~/.alfred-afwf/my_filter.log")
def main(query: str) -> afwf.ScriptFilter:
...
@afwf.log_error(log_file="~/.alfred-afwf/my_filter.log", tb_limit=5, max_bytes=200_000, backup_count=1)
def main(query: str) -> afwf.ScriptFilter:
...
Parameters: log_file (default ~/.alfred-afwf/error.log), tb_limit (traceback depth), max_bytes (default 500 000), backup_count (default 2).
Log format: [YYYY-MM-DD HH:MM:SS] + traceback + 60-char separator. Transparent on happy path — zero overhead.
Uses @functools.wraps, so main.__wrapped__ gives you the original undecorated function (important for testing — see below).
Fuzzy Matching (afwf[fuzzy])
Install: uv add "afwf[fuzzy]" or pip install "afwf[fuzzy]"
opt.fuzzy_item — For Alfred Items (recommended)
import afwf.api as afwf
import afwf.opt.fuzzy_item.api as fuzzy_item
item = fuzzy_item.Item(title="Alfred App", subtitle="https://alfredapp.com/")
item.set_fuzzy_match_name("Alfred App")
items = [...]
matcher = fuzzy_item.FuzzyItemMatcher.from_items(items)
matched = matcher.match("alfred", threshold=70, limit=20)
return afwf.ScriptFilter(items=matched if matched else items)
opt.fuzzy — Generic (for non-Item domain objects)
from afwf.opt.fuzzy.api import FuzzyMatcher
import dataclasses
@dataclasses.dataclass
class Bookmark:
title: str
url: str
class BookmarkMatcher(FuzzyMatcher[Bookmark]):
def get_name(self, item: Bookmark) -> str | None:
return item.title
matcher = BookmarkMatcher.from_items(bookmarks)
results = matcher.match("alfred", threshold=0)
match() parameters:
threshold=70 — minimum score 0–100; use 0 to return all sorted by score
limit=20 — max results
filter_func=lambda name, score, index: True — extra filter applied after scoring
Items with the same get_name() value are all returned when that name matches.
Typed Disk Cache (afwf[cache])
Install: uv add "afwf[cache]" or pip install "afwf[cache]"
Alfred runs Script Filters on every keystroke. Cache expensive calls so Alfred stays responsive:
import afwf.api as afwf
from afwf.opt.cache.api import TypedCache
from pathlib import Path
cache = TypedCache(Path.home() / ".alfred-afwf" / ".cache")
@cache.typed_memoize(expire=60)
def fetch_data(query: str) -> list[str]:
...
@afwf.log_error()
def main(query: str) -> afwf.ScriptFilter:
data = fetch_data(query)
...
typed_memoize parameters:
expire=None — TTL in seconds; None = never expires
tag=None — string tag for bulk eviction: cache.evict(tag="my_tag")
name=None — override cache key prefix (default: function's qualified name)
typed=False — when True, f(1) and f(1.0) cached separately
ignore=() — argument names to exclude from the cache key
Preserves type hints (unlike plain diskcache.memoize).
Cache invalidation:
cache.clear()
cache.evict(tag="my_tag")
cache.delete(key)
Note: Each uvx call is a fresh process, but the cache persists on disk between Alfred invocations because it's stored in ~/.alfred-afwf/.cache.
Patterns
Read-Only: Python-Side Fuzzy Filtering
Script is invoked on every keystroke; Python narrows the list.
import afwf.api as afwf
import afwf.opt.fuzzy_item.api as fuzzy_item
ITEMS_DATA = [
{"title": "Python Docs", "url": "https://docs.python.org/"},
{"title": "Alfred App", "url": "https://alfredapp.com/"},
]
@afwf.log_error()
def main(query: str) -> afwf.ScriptFilter:
items = []
for d in ITEMS_DATA:
item = fuzzy_item.Item(title=d["title"], subtitle=d["url"])
item.set_fuzzy_match_name(d["title"])
item.open_url(d["url"])
items.append(item)
if query:
matcher = fuzzy_item.FuzzyItemMatcher.from_items(items)
matched = matcher.match(query, threshold=0)
items = matched if matched else items
return afwf.ScriptFilter(items=items)
Alfred plist: argumenttype=1 (required), alfredfiltersresults=false
Read-Only: Alfred-Side Filtering
Script runs once; Alfred filters the returned list as the user types (no re-invocation).
import afwf.api as afwf
from pathlib import Path
@afwf.log_error()
def main() -> afwf.ScriptFilter:
sf = afwf.ScriptFilter()
for p in sorted(Path("src/").glob("*.py")):
item = afwf.Item(title=p.name, subtitle=f"Open {p.name}")
item.match = p.name
item.autocomplete = p.name
item.open_file(path=str(p.resolve()))
sf.items.append(item)
return sf
Alfred plist: argumenttype=2 (no argument), alfredfiltersresults=true
Write Action: run_script + send_notification
Phase 1 (Script Filter, every keystroke): build the command string.
Phase 2 (user presses Enter): Alfred's Run Script widget executes the command.
import sys
import afwf.api as afwf
from pathlib import Path
path_file = Path.home() / ".alfred-afwf" / "file.txt"
def _build_cmd(content: str) -> str:
bin_cli = Path(sys.executable).parent / "my-workflow"
return f"{bin_cli} write-file-request --content {content!r}"
@afwf.log_error()
def main(query: str) -> afwf.ScriptFilter:
item = afwf.Item(title=f"Write: {query}", subtitle=str(path_file))
item.run_script(_build_cmd(query))
item.send_notification(title="File written", subtitle=query)
return afwf.ScriptFilter(items=[item])
def write_request(content: str) -> None:
"""Entry point for Alfred's Run Script widget — NOT a Script Filter."""
path_file.parent.mkdir(parents=True, exist_ok=True)
path_file.write_text(content)
Alfred workflow graph:
Script Filter → Conditional (run_script=y) → Run Script {query}
└→ Post Notification
Title: {var:send_notification_title}
Subtitle: {var:send_notification_subtitle}
Conditional Display: Error State
import afwf.api as afwf
from pathlib import Path
path_file = Path.home() / ".alfred-afwf" / "file.txt"
@afwf.log_error()
def main() -> afwf.ScriptFilter:
if not path_file.exists():
item = afwf.Item(
title=f"{path_file} does not exist",
icon=afwf.Icon(path=afwf.IconFileEnum.error),
valid=False,
)
else:
content = path_file.read_text()
item = afwf.Item(title=str(path_file), subtitle=content)
return afwf.ScriptFilter(items=[item])
Settings Store Pattern
Use a JSON-backed dict for persistent workflow settings:
import json
from enum import Enum
from pathlib import Path
class SettingsKeyEnum(str, Enum):
username = "username"
theme = "theme"
class _JsonSettings:
def __init__(self, path: Path):
self._path = path
def __getitem__(self, key: str):
if not self._path.exists():
return None
return json.loads(self._path.read_text()).get(key)
def __setitem__(self, key: str, value: str):
data = json.loads(self._path.read_text()) if self._path.exists() else {}
data[key] = value
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(json.dumps(data))
settings = _JsonSettings(Path.home() / ".alfred-afwf" / "settings.json")
Testing
All tests use plain pytest — no Alfred needed.
Pattern 1: Standalone file with coverage
from my_pkg.search_items import main
class TestMain:
def test_empty_query_returns_all(self):
sf = main(query="")
assert len(sf.items) > 0
def test_query_filters(self):
sf = main(query="python")
assert any("Python" in item.title for item in sf.items)
def test_no_match_returns_full_list(self):
sf = main(query="zzz_no_match_zzz")
assert len(sf.items) > 0
if __name__ == "__main__":
from afwf.tests import run_cov_test
run_cov_test(__file__, "my_pkg.search_items", preview=False)
Run single file: python tests/test_search_items.py → pytest + coverage for that module only.
Run full suite: mise run cov
Pattern 2: Monkeypatching module-level state
import my_pkg.write_file as mod
def test_creates_file(self, tmp_path, monkeypatch):
p = tmp_path / "file.txt"
monkeypatch.setattr(mod, "path_file", p)
mod.write_request("hello")
assert p.read_text() == "hello"
For singletons imported at module load time, patch both modules:
import my_pkg.settings as settings_mod
import my_pkg.set_settings as mod
patched = _JsonSettings(tmp_path / "settings.json")
monkeypatch.setattr(settings_mod, "settings", patched)
monkeypatch.setattr(mod, "settings", patched)
For cache + memoized functions (decorator applied at import time, must re-apply):
from afwf.opt.cache.api import TypedCache
import my_pkg.memoize_handler as mod
mod.cache = TypedCache(tmp_path / ".cache")
mod._get_value = mod.cache.typed_memoize(expire=5)(lambda key: 42)
Pattern 3: Testing log_error via __wrapped__
log_error uses @functools.wraps, so main.__wrapped__ is the original function. Re-decorate with a tmp log path:
import afwf.api as afwf
import my_pkg.search_items as mod
def test_error_writes_log(self, tmp_path):
log_file = tmp_path / "test.log"
patched_main = afwf.log_error(log_file=log_file)(mod.main.__wrapped__)
with pytest.raises(ValueError, match="simulated error"):
patched_main(query="error")
assert log_file.exists()
content = log_file.read_text()
assert "ValueError" in content
Pattern 4: Assert on model, not serialized JSON
item = sf.items[0]
assert item.variables["run_script"] == "y"
assert "write-file-request" in item.arg
assert item.icon.path == str(afwf.IconFileEnum.error)
assert '"run_script": "y"' in json.dumps(sf.to_script_filter())
Don't test: Alfred widget routing, to_script_filter() format, uvx invocation.
Deployment
Dev (local venv)
Alfred Script field: .venv/bin/my-workflow search-items --query '{query}'
Production (uvx)
~/.local/bin/uvx --from "my-pkg==1.0.1" my-workflow search-items --query '{query}'
~/.local/bin/uvx --from "my-pkg[fuzzy,cache]==1.0.1" my-workflow search-items --query '{query}'
uvx downloads, caches, runs — no virtualenv. Subsequent calls use cache; latency is negligible.
Release checklist
- Bump version in
_version.py (and pyproject.toml if separate)
- Run full test suite:
mise run cov
uv build && uv publish
- Update
script field in Alfred's plist for each Script Filter node
- Re-export
.alfredworkflow bundle if distributing to users
info.plist Script Filter node — key fields
<key>keyword</key> <string>my-search</string>
<key>script</key> <string>.venv/bin/my-workflow search-items --query '{query}'</string>
<key>argumenttype</key> <integer>1</integer>
<key>alfredfiltersresults</key> <false/>
<key>withspace</key> <true/>
Keep info.plist in repo with dev paths. Production uvx paths live only in Alfred's installed copy.
Quick Reference
import afwf.api as afwf
import afwf.opt.fuzzy_item.api as fuzzy_item
from afwf.opt.cache.api import TypedCache
from pathlib import Path
@afwf.log_error()
def main(query: str) -> afwf.ScriptFilter:
item = afwf.Item(title="Hello", subtitle=query)
item.open_url("https://example.com")
return afwf.ScriptFilter(items=[item])
| Need | Use |
|---|
| Return items to Alfred | afwf.ScriptFilter(items=[...]).send_feedback() |
| Open URL on Enter | item.open_url(url) |
| Open file on Enter | item.open_file(path=str_path) |
| Run shell command on Enter | item.run_script(cmd) + sys.executable trick |
| Notify on Enter | item.send_notification(title, subtitle) |
| Alt subtitle on ⌘+Enter | item.set_modifier(afwf.ModEnum.cmd, subtitle=..., arg=...) |
| Bundled error icon | afwf.Icon(path=afwf.IconFileEnum.error) |
| Parse query tokens | afwf.Query.from_str(query).n_trimmed_parts |
| Fuzzy filter items | fuzzy_item.FuzzyItemMatcher.from_items(items).match(query) |
| Cache slow calls | @cache.typed_memoize(expire=60) where cache = TypedCache(Path.home() / ".alfred-afwf" / ".cache") |
| Log errors to disk | @afwf.log_error() or @afwf.log_error(log_file="~/.alfred-afwf/my.log") |
| Disable item (Enter does nothing) | item.valid = False |
| Alfred filters results (client-side) | Set item.match = "..." and alfredfiltersresults=true in plist |