The afwf Example Workflow: Architecture and Development Model¶
The afwf library ships a complete, runnable Alfred workflow that
demonstrates every pattern covered in the next four documents. This document
explains the architecture of that example workflow and the development model
behind it.
Install the workflow from the repo root to follow along interactively:
afwf-dev.alfredworkflow ← double-click to install in Alfred
The source of truth is the plain-text plist file alongside it:
info.plist ← human-readable XML; captures everything the Alfred UI shows
The Three-Layer Development Model¶
afwf-based workflows are built in three independent layers:
┌─────────────────────────────────────────────────────────┐
│ Layer 1 — Python logic │
│ afwf/examples/*.py │
│ Pure functions; no Alfred dependency. │
│ Unit-tested in isolation. │
├─────────────────────────────────────────────────────────┤
│ Layer 2 — CLI entry point │
│ afwf/examples/cli.py → afwf-examples │
│ fire.Fire(Command) exposes each main() as a subcommand │
│ Alfred calls this binary from its Script field. │
├─────────────────────────────────────────────────────────┤
│ Layer 3 — Alfred workflow config │
│ info.plist │
│ Declares keywords, Script Filter nodes, Conditional │
│ branches, and action widgets. Static; never changes │
│ when Python logic changes. │
└─────────────────────────────────────────────────────────┘
This separation means:
Python logic is testable with plain
pytest— no Alfred needed.Adding a new feature only changes Python code; the workflow graph is untouched.
Deploying a new version is a version bump in PyPI; Alfred users upgrade via
uvx.
The CLI Entry Point¶
pyproject.toml declares afwf-examples as a console script entry point:
[project.scripts]
afwf-examples = "afwf.examples.cli:main"
afwf/examples/cli.py wraps every Python main() function as a
fire.Fire subcommand:
# -*- coding: utf-8 -*-
import json
import fire
from afwf.api import ScriptFilter
def dump_sf(sf: "ScriptFilter") -> str:
return json.dumps(sf.to_script_filter(), indent=4)
class Command:
def search_bookmarks(self, query: str = ""):
from afwf.examples.search_bookmarks import main
main(
query=str(query)
).send_feedback()
def memoize(self, query: str = ""):
from afwf.examples.memoize import main
main(
query=str(query),
).send_feedback()
def open_file(self):
from afwf.examples.open_file import main
main().send_feedback()
def read_file(self):
from afwf.examples.read_file import main
main().send_feedback()
def write_file(self, query: str = ""):
from afwf.examples.write_file import main
main(query=str(query)).send_feedback()
def write_file_request(self, content: str = ""):
from afwf.examples.write_file import write_request
write_request(content=str(content))
def view_settings(self):
from afwf.examples.view_settings import main
main().send_feedback()
def set_settings(self, query: str = ""):
from afwf.examples.set_settings import main
main(query=str(query)).send_feedback()
def set_settings_request(self, key: str = "", value: str = ""):
from afwf.examples.set_settings import set_settings_request
set_settings_request(key=str(key), value=str(value))
def main():
fire.Fire(Command)
if __name__ == "__main__":
main()
Each subcommand maps directly to one Script Filter keyword in Alfred. The
*-request subcommands (write-file-request, set-settings-request)
are not Script Filter endpoints — they are CLI-only entry points called by
Alfred’s Run Script widget (see Pattern: Write Actions via Run Script).
Two Invocation Modes¶
The script field in each Script Filter node has two forms depending on
the deployment context:
Dev / local — uses the project’s virtual environment directly.
This is what info.plist contains in the repo:
/Users/sanhehu/Documents/GitHub/afwf-project/.venv/bin/afwf-examples \
search-bookmarks --query '{query}'
Production — calls the package via uvx, which downloads, caches, and
runs the pinned version without any pre-installed virtualenv:
~/.local/bin/uvx --from afwf==1.0.1 \
afwf-examples search-bookmarks --query '{query}'
The only thing that changes between the two modes is the binary path. The subcommand name and arguments are identical.
info.plist Structure¶
Alfred stores the entire workflow definition in a single plist file. Understanding its structure makes it possible to inspect or edit the workflow without opening Alfred’s GUI (where screenshots lose detail).
Top-level keys
Key |
Content |
|---|---|
|
Array of all nodes (Script Filters, Conditionals, action widgets) |
|
Dict mapping source node UID → list of wired destination UIDs |
|
Dict mapping node UID → |
|
Workflow display name ( |
|
Unique reverse-DNS identifier ( |
A Script Filter node
Each entry in objects with type = alfred.workflow.input.scriptfilter
has a config dict. The fields that matter most:
<key>keyword</key>
<string>afwf-search-bookmarks</string> <!-- Alfred trigger keyword -->
<key>script</key>
<string>.venv/bin/afwf-examples search-bookmarks --query '{query}'</string>
<key>argumenttype</key>
<integer>1</integer> <!-- 0=optional 1=required 2=no argument -->
<key>withspace</key>
<true/> <!-- keyword must be followed by a space before firing -->
<key>alfredfiltersresults</key>
<false/> <!-- false = Script Filter handles filtering in Python
true = Alfred filters the returned list client-side -->
Connections
The connections dict is keyed by the source node’s UID. Each value is
an array of destination objects. A sourceoutputuid of
4D36294E-9A14-49FB-A0F4-62E21227E74D in a Conditional node means “the
matching (y) branch output”:
<key>2219C90E-2659-4B5A-BDB1-B1A0375C5501</key> <!-- Conditional: open_url=y -->
<array>
<dict>
<key>destinationuid</key>
<string>2FE6CFA4-74F5-41A7-A2CA-43A4D999A92D</string> <!-- Open URL widget -->
<key>sourceoutputuid</key>
<string>4D36294E-9A14-49FB-A0F4-62E21227E74D</string> <!-- "y" branch output -->
</dict>
</array>