This is the complete, language-agnostic contract between Flash and a plugin.
Any executable that speaks it over stdio is a valid plugin. The maintained
Rust SDK (docs/plugin-rust-sdk.md) is the blessed implementation, not a wall.
Normativity order: Plugins/_flash_plugin_rust/protocol.json (the
machine-readable constants: version, deadlines, quotas, error strings,
capability registry, transport limits) is the specification, and the
Rust SDK's wire/runtime tests plus its probe crate
(Plugins/_flash_plugin_rust/probe) are the conformance authority that
proves it. This prose explains the contract; the host asserts its constants
against the same file; any divergence is a bug in the derived artifact, never
in the contract.
Plugins are Flash-owned child processes. Official plugins ship inside the app
bundle; third-party plugins are listed in [plugins] third_party as
github:user/project@<commit-sha> (full 40-character SHA, mandatory — the
materializer fetches exactly that commit and refuses a mismatched checkout) or
file:<path>. The manifest's optional install shell string (third-party
only) runs sandboxed from the plugin root on an asynchronous install job.
The job owns a process group, drains both output pipes while it runs, retains
at most 4 MiB stdout / 256 KiB stderr, and escalates timeout or cancellation
from SIGTERM to SIGKILL after 500 ms. A stopped/replaced attempt cannot write
an install stamp or launch a child. Install attempts for the same canonical
root run in submission order across process replacements. Cancellation keeps
ownership until the old group exits; cancelled waiting attempts never spawn.
exec is an argv array exec'd
directly with the scrubbed plugin environment — no shell wrap. Its first
element resolves in order: absolute paths pass through, ./-style paths
resolve against the plugin root (official Rust plugins), and bare names resolve
through mise which then a login-PATH walk. This keeps the protocol open to
third-party executables without adding runtime bridges to the official bundle.
Runtime children receive FLASH_PLUGIN_ID, FLASH_PLUGIN_VERSION,
FLASH_PLUGIN_DATA_DIR, FLASH_PLUGIN_PARENT_PID, and the plugin's
[plugin.<id>] settings as a JSON object in FLASH_PLUGIN_CONFIG. Flash
builds a scrubbed environment containing only basic locale/path/process keys
plus those values; ambient tokens, agent sockets, SDK search paths, and
login-shell secrets are not inherited — put credentials in the plugin's
config table. Install CLI tools under FLASH_PLUGIN_DATA_DIR, never into
global shell paths. A child is spawned only once the host's login environment
has resolved, so its PATH is the user's; a resolution that fails keeps the
seeded one.
Activation. A plugin is resident — spawned at startup and kept running —
iff its manifest declares sources, query, hints, status, or listen.
A plugin declaring only on-demand surfaces (commands, bangs,
navigation) is on-demand: it stays unspawned until its first perform,
which launches it (the perform deadline comfortably absorbs the startup
budget) and then it stays running normally. Manifest-only plugins (no exec)
never spawn at all.
A status-bound plugin — status with no sources, query or hints, its
listen subscriptions presumed to feed those segments — is resident only while
the enabled status bar, one of its popups or a desktop widget
shows one of its segments; otherwise it is on-demand. A config reload that
starts showing it spawns it; one that stops showing it stops the process and
returns it to on-demand, unless a command started it, since on-demand plugins
stay running once a command needs them. :plugins reports the effective
activation.
Lifecycle state machine.
initial / idle → installing → launching → running
│ │ │
└────────────┴──────────┴── failure → backoff → installing
│
budget exhausted → failed
stop → stopped (all attempt work invalidated)
launching = spawned, awaiting the initialize reply; the host dispatches no
other requests and no events until running. Restarts back off linearly
(1..30 s); after five retries within 300 s, another failure parks the plugin in failed (recover with
:plugins reload or a plugin-file change). Successful initialize does not
reset the rolling failure budget; even an initialize-then-crash loop parks,
and a status-bound plugin that stops being observed keeps its budget too.
Lifecycle transitions belong to attempt generations. Stop/reload invalidates
pending retries, installation completions, and transport work. Rejected
initialize replies park immediately. The published catalog survives
crashes and restarts (see publish) and is dropped on failed or unload.
A planned reload keeps the previous status values for up to ten seconds,
until each is republished, measured from the newest replacement; any other
teardown clears them. In-flight performs settle as errors.
Asynchronous host RPC replies stay attached to the child that requested them:
a replacement child can reuse an ID without receiving the old child's reply.
Explicit reload and file changes reconcile the manifest through PluginManager.
Unchanged definitions restart only the affected binaries and retain catalogs.
A changed valid definition rebuilds its registrations, authorization, adapter,
and process together and drops its previous catalog. Its requests route to
the replacement from the moment it is reconciled: they wait while the old
process stops, and the replacement starts only once it has. An invalid replacement
keeps the last validated definition running and reports its load error; fixing
the file allows reconciliation to recover without a resident-app restart.
A plugin should also exit when FLASH_PLUGIN_PARENT_PID dies.
Shutdown. There is no shutdown method. The host closes the plugin's
stdin; stdin EOF is the shutdown signal — run cleanup, exit 0. The host
waits shutdown_grace (1 s), then SIGTERM, then (+0.5 s) SIGKILL.
Liveness. There is no periodic heartbeat. The process termination handler
catches child exit; a hung request is caught by its own deadline. The one residual probe: if
the host has received no frame at all from a plugin for idle_before_ping
(60 s) and has nothing in flight, it sends ping; one missed reply (10 s)
tears down and restarts. Any plugin frame — a publish, a log line, a response
— resets the idle clock. A blocking single-threaded plugin is fully
conformant: pings never race in-flight requests. The host checks idleness on
one shared low-priority sweep at half that threshold (registered only while a
plugin runs, held while the displays sleep), so the ping follows 60–90 s of
silence; a chatty plugin is never pinged and costs no wake-up.
NDJSON on stdin/stdout: UTF-8, one JSON object per newline-terminated line, no
envelope beyond id/method/params/result (id+method = request,
id alone = response, method alone = notification), plus the host's
optional trace on a request: the id (^[0-9a-z]{1,16}$) of the user
interaction that caused it. A log notification emitted while serving that
request carries it back as params.trace (the Rust SDK does both), so one
interaction reads end to end across processes; see
observability. Every request also carries deadline_ms,
the milliseconds the host still waits for its reply as it writes it. Ids are positive
monotonic integers per sender. Booleans and floating-point values are never
integer IDs or protocol versions; ok and other Boolean fields accept only
JSON booleans. Native process IDs are checked positive 32-bit integers.
Host and plugin counters are independent and
may overlap — an inbound id+method frame is always a request, never a
reply. Exactly one reply per id'd request; responses to unknown ids are
dropped. There is no cancellation message: late replies are dropped and
the deadline table is the contract. A plugin may stop a handler whose answer
would come too late: the Rust SDK drops a search or hints handler still
running a tenth of deadline_ms (at most 50 ms) before it, which answers
{"ok": false, "error": "deadline exceeded"} and logs a warning under the
request's trace; evaluate and perform run to completion. Lines are capped at 10 MiB in both
directions, excluding the terminating newline; an undecodable or oversized line is dropped (never fatal) and the
stream self-heals at the next newline. An outbound response that would exceed
the cap is replaced by {"ok": false, "error": "response exceeded outbound frame limit"} under the same id. stderr is diagnostics only — lines are
logged but never treated as plugin failure; use the log notification for
structured logging (recorded with source = "plugin:<id>"); stderr is logged
as whole lines, each capped at 4 KiB, at most 20 per 10 s, and the rest are
counted. Geometry is
always NSScreen coordinates.
The response law. Every result is a JSON object carrying boolean ok.
ok: false carries a non-empty, content-free error string — with one
deliberate exception: perform's {"ok": false, "unhandled": true} (see
below). Protocol-level failures use the canonical exact strings from
protocol.json (unknown method: <m>, protocol version mismatch: host v<H>, plugin v1, initialize may only be called once, …); domain failures
are free-form but never contain query text, candidate data, clipboard
content, or config values.
One table, in protocol.json, that the host, the Rust SDK, and this doc
all share:
| name | value | applies to |
|---|---|---|
startup |
5 s (config [plugins] startup_timeout) |
initialize |
query |
50 ms | evaluate |
live |
1000 ms (config [flashlight] live_query_timeout_ms) |
search |
hints |
500 ms (fixed; blocks the AX queue ahead of the prepared model) | hints |
perform |
10 s (per-entry commands[].timeout_ms overrides) |
perform |
ping |
10 s | ping reply |
idle_before_ping |
60 s | inbound silence before a ping |
shutdown_grace |
1 s | stdin-close → SIGTERM (+0.5 s → SIGKILL) |
Notifications have no deadlines.
-
initialize—{"protocol_version": 1}→{"ok": true, "protocol_version": 1}. Reply immediately — no warm-catalog wait, no startup work first;on_starthooks run after the reply and publish when ready. The reply always echoes the plugin's own protocol version (the stale-binary diagnostic). A version mismatch →{"ok": false, "protocol_version": 1, "error": "protocol version mismatch: host v<H>, plugin v1"}, then flush and exit 0; anyok: falseinitialize reply is terminal and parks the plugin infailed(no auto-restart; file watchers stay armed so a rebuilt binary recovers). A repeatedinitializeon a healthy plugin is the one non-terminal protocol NAK: reply{"ok": false, "error": "initialize may only be called once"}and keep serving. No reply withinstartup→ teardown + backoff restart. Immediately after the reply, the host delivers onecore:apps.changedevent (to plugins whoselistenmatches) carrying the full running-applications snapshot. -
ping—{}→{"ok": true}. See Liveness. -
event(notification) —{name, payload}, filtered by manifestlistenglobs:core:flash.started,core:apps.changed|launched|terminated,core:focus.changed,core:window.focus.changed,core:ax.changed({pid, notification, bundle_id?}: one AX notification an observed app posted, amongprotocol.jsonhost_events.ax_notifications; every keystroke postsAXValueChanged, so match the notification rather than debouncing every change alike),core:config.changed,core:power.changed,core:network.changed(interfaces, addresses, routes or DNS changed),core:volumes.changed(a volume mounted, unmounted or was renamed),core:space.changed,core:status.observed(which of the plugin's own status segments a surface shows; see Status observation), andcore:session.opened(the flashlight opened; advisory — eager plugins may refresh, nothing is required).protocol.jsonhost_eventspins the list.core:network.changedandcore:volumes.changedcarry an empty payload — no interface, address, network name or volume — and coalesce a burst of OS notifications into one event within 500 ms; re-read what you show instead of polling for it. The host observes the network (System Configuration's dynamic store) and volume mounts (workspace notifications) only while at least one plugin'slistenmatches the event. -
evaluate—{query, scope, surface}→{"ok": true, "answers": [...]}. The per-input evaluator: synchronous, CPU-only over state prepared earlier — no I/O. Unclaimed input answers{"ok": true, "answers": []}— evaluators are additive parsers, never error paths. -
search—{query, scope}→{"ok": true, "rows": [...]}in catalog row shape. Live-source pull for manifests whosesources[]declarelive: true, dispatched per flashlight keystroke but ONLY when the query is explicitly scoped to the source (@sourcefilter or a bang'ssource) — live sources never join the default pool or the first paint. The handler may do real work (subprocess, disk); late replies are dropped, not fatal.livecannot combine withkind: "locations", and one plugin's sources are all-warm or all-live. -
hints—{bundle_id, pid, front_window_frame}→{"ok": true, "targets": [...], "context_pid"?}. Always a live request; there is no cached-discovery path. The host owns commit: it posts the mouse event directly to the target app and never calls back into the plugin to activate a target. Use roleAXLinkfor native-style semantic links (fplain;FCommand-Shift), orFlashTerminalLinkfor links inside terminal content (fShift,FCommand-Shift). Every target preserves the requested click modifiers. Targets:{id, frame{x,y,width,height}, role?, label?, url?, pid?, context_id?, enters_insert_mode?, priority?}. The nestedframeis required; flat coordinates and unknown target fields are rejected. Geometry must be finite with positive width/height. One malformed target rejects the entire reply.context_idis an optional non-empty opaque string identifying the live source context containing the target. The host requires an exact match when revalidating a captured hint; omitted/null means unspecified. Tmux includes its backend, client tty, server process, session, window and pane identities, so a reused pane label in another session cannot receive a captured click. -
perform— the single effect method. Four kinds:{"kind": "resolve", "row": {…}} {"kind": "command", "command": "…", "subcommand": "…", "args": […], "raw": "…"} {"kind": "action", "name": "tab_select", "context": {"bundle_id", "pid", "front_window_frame"}, "args"?: {…}} {"kind": "navigate", "url": "tmux://…"}Uniform reply, universal trichotomy:
{"ok": true, "target_pid"?: 123, "navigation_url"?: "…", "message"?: "…"} {"ok": false, "unhandled": true} {"ok": false, "error": "…"}ok: true= performed (target_pidraises that app and records the jump in movement history;messageshows as a toast).unhandled= "not my context" — the host MAY fall back (e.g. to a keystroke mapping). Anerror= "mine, but it broke" — the host MUST NOT fall back (double-fire protection); no reply / timeout / crash coerces to this case. Bang dispatch arrives askind: "command"with the bang token as subcommand. Unknownkind→ error. The host never dispatchesperformto a plugin infailed/unspawnable state — it settles asunhandledwithout burning the deadline (nothing could have started).
The replacement kinds in protocol.json host_events.replacement are state
signals: each one supersedes the earlier ones with the same coalescing key.
The key is the event name, except for core:ax.changed, which reports one
fact per app and notification and adds the payload pid and notification
— a keystroke's AXValueChanged never replaces a pending AXTitleChanged,
and one app's change never replaces another's.
The host queues every frame for a child's stdin in send order under the 256-frame / 20 MiB transport budget. When it queues a replacement event while an unsent one with the same key is still waiting, it drops the older frame and appends the newer, so a child reads a subsequence of the emitted events, in emission order, and a child that briefly stops reading accumulates at most one frame per key rather than overflowing on a burst of signals. Requests, responses and every other event keep FIFO order and their budget: a child that stops reading those is still restarted once the budget fills. A frame already written to the pipe is delivered; the plugin's own event backlog coalesces what it has read but not yet handled by the same keys (see the SDK's replacement slots).
core:status.observed tells a plugin which of its own status segments a
status surface currently shows, so work that only feeds a segment runs only
while someone can see it. The processes plugin registers its top-N
cadence only while top_cpu or top_mem is observed.
{"method": "event", "params": {"name": "core:status.observed",
"payload": {"segments": ["focused_app_details", "top_cpu"]}}}- Payload.
segmentsis required: the complete set of this plugin's observed segments as bare manifeststatusnames (noflash.plugin.<id>.prefix), each nonempty and listed once. The host sends them sorted; order carries no meaning.[]is authoritative — nothing is observed. Names the manifest does not declare never appear, and no other plugin's segments do. Other payload keys are ignored. - Observed. A segment is observed while a live surface would resolve
#{flash.plugin.<id>.<segment>}: the enabled bar's template, its named popups, and desktop widgets. These are the references that keep a status-bound plugin resident, refined from plugin ids to segment names. A disabled bar or a removed widget observes nothing. - Delivery. Filtered by
listenlike every other event, and not capability-gated: segment names are manifest data, not user content. The host sends the current set once right after the initialize reply (after thecore:apps.changedsnapshot), even when it is empty, so every restarted child starts from the truth. It sends it again whenever that plugin's set changes — a configuration reload, the bar being enabled or disabled, a widget appearing or disappearing — and never repeats an unchanged set. Observation does not change activation or residency. - Coalescing. The set is a full replacement, coalesced like every replacement event (see Event coalescing), so intermediate sets may be skipped but the final set arrives.
- Rejection. A payload whose
segmentsis missing, null, not an array of strings, or contains an empty or duplicate name is malformed. The Rust SDK drops it whole with a content-free warning and does not deliver it. Thestatus_observedgroup infixtures/wire-values.fixturepins these cases; host tests of the payload it builds can consume the same group.
A plugin keeps the segments it publishes correct regardless: observation
schedules work, it does not filter what the host renders. A plugin that
stops sampling for an unobserved segment should clear it (""), so a
surface that shows it again never reads stale figures.
Notifications:
publish—{"rows": [...]}. The catalog is push-based. A full replacement of the plugin's warm catalog, accepted any time after spawn; the host validates quotas at receipt (on the plugin's own reader queue) and owns the store — the flashlight first paint reads host memory and never waits on a plugin.{"rows": []}is an authoritative empty. On transient refresh failure, simply don't publish — the host keeps the last-good catalog by construction, across crashes and restarts (rows must therefore be resolvable from their own content; a restarted plugin that cannot resolve an old row replies{"ok": false, "error": …}to itsperformand the next publish replaces the rows). A malformed or over-quota publish is rejected whole (content-free log) and the previous catalog is retained. An open flashlight refreshes from the store on a coalesced ≤1/s tick — lossless, since the store is already current.status—{"segments": {"name": value}}for manifest-declared status segments, rendered as#{flash.plugin.<id>.<segment>}. A value is markup (""clears) or a carousel object{"prefix"?: markup, "lines": [markup], "cycle_seconds": >= 1}: the host rotates the lines on its own clock, only while a visible surface shows the segment (a republish keeps the visible line until its scheduled rotation), drawsprefixstill before the visible line, and wraps that line in#[cyc]…#[nocyc]for the carousel transition; blank lines are dropped, no lines clears, and a malformed object is rejected whole with a content-free warning. Undeclared names are ignored. Segments are live state: cleared on any teardown (unlike catalogs).poll—{"registrations": {"<name>": {"every": <seconds>, "priority": <p>} | {"after": <seconds>, "priority": <p>}}}, the plugin's complete set of cadences (every) and one-shot deadlines (after, measured from receipt) it wants the host to drive; an empty set stops its polling. Plugins must not arm their own timers or sleep to schedule work. The host folds every registration in the app onto one clock (see the architecture guide) and deliversevent {"name": "core:poll:<name>"}when one is due, bypassing manifestlistenbecause the registration is the subscription. A deadline fires once and leaves the host's clock; repeating it unchanged in a later set neither re-arms nor re-fires it, while a new or changed entry arms afresh, so give each arming its own name (the Rust SDK does).priorityis how late a wake-up may land, the slack that lets unrelated wake-ups coalesce:high(25 ms) for a value on screen that the user watches change,normal(100 ms) for ordinary sampling, settles and refreshes,low(1 s) for background upkeep, remote pulls, retries and backoffs; the host'ssystempriority is core-only. Names are[a-z0-9_-]{1,64};everyis floored at 0.05 s, both are capped at 86400 s, and a set holds at most 64 registrations (protocol.jsonpoll). A malformed or over-bound frame is rejected whole and the previous set keeps running. Ticks are one-way and unacknowledged, so a plugin whose previous callback is still running drops the ticks it missed rather than running back-to-back to catch up. No tick is delivered while the displays sleep, the session is locked or switched out, or the system is going to sleep; when that ends, a registration that fell due meanwhile ticks once. Every registration ends with the plugin process. Reach for a cadence only when no event can tell you the value changed.log—{"level", "message", "fields"}. Content-free (counts, stages, elapsed ms, method names — never query text, candidate data, clipboard content, config values, or event payloads).
Host RPCs, capability-gated default-deny (checked per process against the
manifest; the full method↔capability table lives in protocol.json):
host.ping, host.fetch (network_fetch), host.normal_mode_target
(which also reports the focused window's WindowServer window_id, so a plugin
can name a window without asking the user to pick one) +
host.activate (app_control), host.open (open), host.post_media_key
(media_keys), host.process_table + host.signal (process_control),
host.clipboard_write (clipboard), host.notify (notify),
host.wifi_info (wifi_info),
host.storage_get/host.storage_set (no capability — plugin-data-dir
scoped), host.post_keys + host.post_global_key + host.ax_snapshot /
host.ax_perform / host.ax_set / host.ax_select_child
(accessibility). A denied call answers {"ok": false, "error": "missing <capability> capability"}.
host.clipboard_write replaces the clipboard with {"text"} (≤ 1 MiB).
host.wifi_info requires {"request_authorization": false} for passive reads
or true only for an explicit user action allowed to show the host-owned
Location prompt. An undetermined request replies absent immediately after
starting the prompt; callers retry after authorization instead of holding an
RPC open. The method returns
{"ok": true, "present": true, "ssid": "…"} for an associated network or
{"ok": true, "present": false} when Wi-Fi information is unavailable.
host.notify shows a transient banner from {"message", "duration_ms"?}
(message ≤ 1 KiB, duration clamped 500–10000 ms, one accepted call per
plugin per second). host.storage_get/host.storage_set are a host-managed
KV store persisted to storage.json in the plugin's data dir ({"key"} /
{"key", "value" | null}; keys ≤ 128 B, values ≤ 64 KiB, 256 entries; null
deletes). host.open takes {"url"} or {"bundle_id"}; a URL's reply also
names the app LaunchServices handed it to (bundle_id, when resolvable), so a
plugin can confirm that app still has focus before acting on it.
host.post_global_key accepts one {key_code, modifiers} chord
posted through the host's session event stream for macOS-owned shortcuts and
rejects unmodified input. The AX broker exists because AXUIElement cannot
cross a process boundary: host.ax_snapshot BFS-walks a subtree (default
cap 3000 nodes) and returns flat nodes with opaque handles; geometry in
NSScreen coordinates. An optional deadline_ms bounds the walk from the
call's arrival: past it the reply carries the nodes reached so far and
"truncated": true. capabilities also includes network (composes
network-outbound into the sandbox profile), network_fetch (the host
performs HTTPS GETs on the plugin's behalf via host.fetch, restricted to
the manifest's fetch_urls prefixes, 8 s timeout, 1 MiB UTF-8 cap — the
plugin keeps a fully network-denied sandbox; the two must be declared
together), and subprocess (permits helpers that cannot run inside the
profile). open hands URLs/bundle ids to LaunchServices host-side,
media_keys posts NX_SYSTEM_DEFINED keys host-side, process_control
reads the process table and SIGTERMs pids host-side. wifi_info exposes only
the current SSID after host-owned Location authorization. Omitted capabilities
are default-denied. The registry is frozen: additions are allowed, renames
never.
SDK call_host convention: call_host never
throws and never returns nil — it always yields a result object. Capability
NAKs, host death ({"ok": false, "error": "host closed stdin"}), and the
5 s default timeout ({"ok": false, "error": "host call timed out"}, per-
call override available) all arrive the same way.
Catalog rows (for publish and search) are {"source", "title", "url"?, "metadata"?, "effect"?} — source must name a manifest sources[].name;
routing source_id is always host-stamped and never crosses the wire.
Catalogs are bounded to 10,000 rows / 4 MiB encoded; titles 4 KiB, URLs
16 KiB, metadata 64 entries (256-byte keys, 64-KiB values), effect text
64 KiB. evaluate answers use the deliberately narrower {"answers": [{"title", "subtitle"?, "effect"}]} — URLs, metadata, and unknown keys are
rejected — bounded to 16 answers / 256 KiB with 16-KiB fields. effect runs
only after explicit selection: {"type": "copy_text", "text"}, {"type": "insert_text", "text"}, or — rows only — {"type": "open", "url"} /
{"type": "open", "bundle_id"} (answers reject open for the same reason
they reject URLs: evaluators cannot manufacture navigation). There is
deliberately no run effect — a host-executed argv would escape the
plugin's sandbox; side effects that run commands belong in perform, inside
the sandbox. Every bound rejects the complete payload atomically; nothing is
silently truncated. Array byte quotas count the compact UTF-8 JSON encoding
of rows or answers, including JSON syntax and string escaping, with literal
Unicode and unescaped slashes; the surrounding request/response envelope is
excluded. Publish and search apply the same catalog decoder and byte boundary.
Optional values may be null, but present values must have the declared type.
Perform outcomes form an exclusive union: success fields cannot accompany an
error, and unhandled cannot accompany success or an error string.
Required: id, name, version, description. install is optional and
third-party-only: a shell string run once per version from the plugin root,
sandboxed (writes confined to the plugin root/data dir/temp, secrets
read-denied, network and exec open), with its full output persisted for
forensics. exec (argv array) is required for any plugin that runs a
process; omitting it declares a manifest-only plugin — no child process
ever runs, and the manifest may only carry surfaces the host serves alone:
mappings, help, action_bindings, terminal_emulators and
on_demand_hints (the bundled defaults and terminals plugins are the
exemplars). Anything process-bound is
rejected. Loading is strict — unknown top-level or nested keys and malformed
known fields are rejected outright, and new manifest surface only ever
arrives together with a protocol change, so "unknown key" always means "typo
or stale host", never ambiguity.
{
"id": "example",
"name": "Example",
"version": "0.1.0",
"description": "Example plugin",
"exec": ["./flash-plugin-example"],
"sandbox": { "exec": ["/usr/bin/osascript"], "appleevents": true },
"capabilities": ["app_control"],
"listen": ["core:flash.started", "core:apps.*"],
"only_bundle_ids": ["org.mozilla.firefox"],
"priority": 25,
"sources": [
{ "name": "example.items", "kind": "locations", "priority": "normal", "live": false }
],
"query": { "prefixes": ["="] },
"commands": [
{ "command": "example", "subcommand": "run", "description": "Run example", "timeout_ms": 300000 }
],
"bangs": {
"command": "example",
"items": [
{ "token": "ex", "description": "Search example", "source": "example.items" },
{ "token": "*" }
]
},
"actions": ["resource_archive"],
"hints": { "fallback_on_empty": true },
"navigation": ["example"],
"status": ["state"],
"action_bindings": {
"tab_next": { "": "cmd+shift+]" },
"clipboard_copy": { "": ["cmd+a", "cmd+c"] },
"window_new": { "": { "menu": ["File", "New Window"] } },
"app_print": { "": false }
},
"action_confirmations": { "resource_archive": "Archive it?" },
"mappings": [
{
"key": "ge",
"mode": "normal",
"command": ["flash", "resource_archive"],
"only_bundle_ids": ["org.mozilla.firefox"]
}
],
"help": { "topics": [] }
}Section semantics:
-
sandbox— deny-default seatbelt opt-in (even{}): everything is denied except loading the binary, broad reads minus secrets (~/.ssh,~/.aws, keychains, other plugins' data), and writes inside the plugin's data dir. Allowances:exec(absolute paths or bare tool names resolved throughmise which/login-PATH at spawn),appleevents,signal,mach. Thenetworkcapability composesnetwork-outbound.[plugin.<id>] sandbox = falseis the per-plugin fail-open kill switch;[plugin.<id>] exec_pathsappends machine-specific tool paths. A spec-less plugin runs under a network-deny-only profile, or unsandboxed when it declares thenetworkorsubprocesscapability (browsers, for example: Chromium browsers refuse Apple Events from any sandboxed sender). -
listen— event-name patterns;*wildcard. Declaringlistenmakes the plugin resident. -
sources— source descriptors for@<source>completion and ranking.kindis"default"(default) or"locations";priorityis the semantic salience enumlow | normal | high | important | urgentand sets the source's precedence weight in the flashlight (see flashlight ranking): it orders rows against other sources inside one match tier and never lifts a partial hit above an exact one. Bundled:tmux.windowsisimportant, apps and browser tabshigh.live: truemarks a live (pull) source served bysearch. -
query— registers the per-input evaluator.prefixesare exact literal markers (never regexes) that route matching input only to this evaluator; without one, evaluators are additive. Evaluator ordering is the manifest rootpriority, ties broken by plugin id. -
commands—flash <command> <subcommand>registrations, the same words as:command subcommand. Both words are required. An empty subcommand is rejected, and so is averbsentry (that was a one-wordflash <name>). Subcommand"*"consumes the remainder as args; per-entrytimeout_msoverrides theperformdeadline for interactive commands. A command name that matches a Flash verb, a reserved query, or another plugin's command fails the load of every plugin that claims it. Declaring the same pair twice does too. -
bangs— flashlight bangs;token: "*"is the catch-all; an entry'ssourcescopes live-source completion to that bang. -
actions— source-owned normal-mode action names, dispatched asperform {kind: "action"}. -
hints— opts in as a hint provider (always live). Selection is exclusive; setfallback_on_empty: trueonly when applicability is discovered dynamically and empty means "not applicable". -
navigation— durable route schemes restorable from movement history, dispatched asperform {kind: "navigate"}. -
status— status-bar segment names fed by thestatusnotification. -
action_bindings— what each app does for Flash's actions: action name → bundle id → binding. The bundle id""covers every app the root selector matches. A binding is one of:"cmd+t"— a chord;["cmd+k", "cmd+w"]— chords sent in order,[mode] send_key_interval_msapart;{ "menu": ["File", "New Tab"] }— the menu-bar item at that title path (menu, then item, then any submenu item), pressed through Accessibility off the main thread; a missing or disabled item fails the action with no fallback;false— the app has no such action: nothing happens.
tab_selectbindings may name the tab with{index}("cmd+{index}",{ "menu": ["Window", "Tab {index}"] }); a chord that doesn't exist for the index (cmd+10) sends nothing. Validation is strict: an unknown action, an unparseable or empty chord (""is not "none", writefalse), an empty array,true, a menu path shorter than two titles, an object other than{"menu": [...]}, or{index}outsidetab_selectrejects the manifest.Resolution. An action first goes to the sources that perform it in the focused app (manifest
actions, the built-in Accessibility tab strip); a source that claims it and fails stops there. Otherwise one binding wins, by a pure, deterministic ranking: an entry for the app's bundle id beats any plugin-wide""entry; then a plugin whose root selector (only_bundle_ids,only_terminals) matches the app beats an unscoped one (a plugin whose selector doesn't match contributes nothing); then the higher manifestpriority; then the plugin id, alphabetically first. With no binding nothing happens, except thatresource_next/resource_previousandscroll_top/scroll_bottomfall back to Flash's own scrolling andwindow_closetries the window's close button before its binding. In a terminal emulator a chord the emulator would type as text is refused, unless a plugin binds that chord for that emulator specifically (its bundle id or a matching selector); a menu press is never refused. The inspector's Mappings page shows the resolution for the focused app.Actions:
tab_select,tab_next,tab_previous,tab_first,tab_last,tab_new,tab_close,tab_reopen,tab_move_next,tab_move_previous,pane_next,pane_previous,pane_split_vertical,pane_split_horizontal,pane_close,app_reload,app_reload_force,resource_archive,resource_next,resource_previous,scroll_top,scroll_bottom,history_back,history_forward,app_undo,app_redo,app_find,app_save,app_print,document_open,window_new,window_close,clipboard_copy,clipboard_cutandclipboard_paste. Every one exceptapp_reload_forceis also a valid manifestactionsname. The bundleddefaultsplugin declares the macOS conventions as""bindings; disabling it leaves those actions unbound. Manifest-only plugins may declareaction_bindings. App knowledge lives here, never in the host. -
action_confirmations— the question the host asks before an action this plugin performs destructively: action name → prompt, for names inactionsonly (an empty prompt or an action the plugin does not perform rejects the manifest). In the apps the root selector matches, the host shows the prompt as its confirmation alert ([y]es / [n]o, keys in the hint colour) and dispatches the action only ony; the plugin then performs it without a prompt of its own, which NORMAL mode's capture would swallow the answer to. Across plugins, a matching selector beats an unscoped one, then the higherpriority, then the plugin id. The bundled tmux plugin gatestab_closeandpane_closethis way. A mapping gates any command the same way with["flash", "confirm", "--prompt=…", "--command=<verb [--arg=value]>"]. -
terminal_emulators— bundle ids of apps this plugin declares as terminal emulators. No protocol identifies one, so the host learns them as data: across plugins the union gets the terminal rules (an unbound Command chord is refused rather than typed, pixel wheels are refused, and a primary hint or grid click anywhere in one enters INSERT) and matchesonly_terminals. A terminal link hint stays in NORMAL. The bundled manifest-onlyterminalsplugin declares the common emulators. -
on_demand_hints— bundle ids of apps whose accessibility tree costs too much to warm in the background. Across plugins, the union is walked only when hints are requested and observed with the reduced notification set that drives mode, border and focus. The bundleddefaultsplugin declares Notes. -
mappings— key bindings in any mode a config can map:all | normal | insert | terminal | command | hyper | hold(defaultnormal), under the config's key rules (acommandkey is one modified chord);commandis an argv array with config-mapping syntax; entries may scope withonly_bundle_ids, andrepeat: truerepeats the sequence when its final key is pressed again, as in config. Map keys to high-level actions; an app's own chord for one belongs inaction_bindings, and no bundled mapping usessend_key. Terminal mappings are local to a focused popup. Every global mapping registration is suspended while that view owns input; only winning INSERT-activeenter_normal_modebindings are inherited as terminal defaults, and explicit terminal bindings override them. Terminal sequences use the shared key syntax without<leader>, implicit counts, or register prefixes. See popup input.
only_bundle_ids and only_terminals may appear at the root and on mapping
entries; root and entry selectors compound. only_terminals: true scopes to
the declared terminal emulators (the union of every plugin's
terminal_emulators, see below) so a plugin never carries its own terminal
allowlist; the registry
instantiates such a plugin only while a known terminal runs, and its hints
provider is consulted only in those apps, keeping hint activation elsewhere on
the zero-hop prepared-model path. The numeric manifest priority (default 25) is
scheduling/collision arbitration — do not confuse it with the semantic
sources[].priority salience enum.
Plugins/_flash_plugin_rust/protocol.json pins the constants; the SDK's
cargo test suite pins the behaviour. Wire and lifecycle rules — framing,
ids, noise, the handshake, protocol mismatch, request decoding, notification
shapes, shutdown draining, host-RPC correlation — are WireHarness tests in
Plugins/_flash_plugin_rust/src/runtime.rs, driving the real serve loop over
in-memory NDJSON streams. Plugins/_flash_plugin_rust/fixtures/wire-values.fixture
is the malformed and boundary-value corpus the SDK and the host's XCTest
suites both consume, and the probe crate Plugins/_flash_plugin_rust/probe
drives a generic plugin!-generated plugin through every surface (catalog,
evaluate, search, hints, the perform trichotomy, notifications, events,
host-RPC arms) over the wire. For a protocol defect, land the failing test
there before changing the implementation.
Behaviour specific to one plugin — scripted host replies, the published
catalog, status rows — is a cargo test in Plugins/<id> written against
flash_plugin::testing::Harness. Real process spawning, manifest
reconciliation and shutdown are the host XCTest suites' business;
PluginSandboxExecTests compiles every bundled seatbelt profile and boots
every built sandboxed plugin under its resolved profile with the scrubbed
plugin environment.
The complete gate is:
./Scripts/test-plugins.sh --lane allIt formats and lints every Rust crate, runs the plugin-publication test and every crate's unit tests (the SDK workspace includes the probe), and builds all plugins.
Seatbelt denies are not reopened by a later allow rule. When a denied parent
contains an allowed child, carve the child out inside the deny filter with
require-all and require-not; PluginSandbox.secretsReadDeny is the
canonical pattern for allowing a plugin's own data directory while denying its
siblings.
Seatbelt matches canonical vnode paths. Resolve symlinked executables before
putting them in a profile, and canonicalize temporary fixture roots
(/var/folders/... commonly resolves beneath /private/var/folders/...). A
path that looks equivalent in the shell may otherwise fail silently in the
profile. For diagnostics, sandbox-exec exit 65 means the profile did not
compile, while exit 71 means the target could not be executed under it.
Locked while breaking is still free (the MV3 lesson); changing any of these after external plugins exist is a migration, not an edit:
- One integer
protocol_version, echoed exactly at initialize; never per-feature wire negotiation — features are manifest declarations. - The response law and the canonical error strings in
protocol.json. - Id semantics and no-cancellation (late replies dropped).
- UTF-8 NDJSON framing, 10 MiB line cap, NSScreen geometry.
- The quota table and the atomic-rejection doctrine.
- The capability registry: additions allowed, renames never, granular over coarse.
- The
performed / unhandled / errortrichotomy as the universal action vocabulary, including "error must not fall back". - Strict manifest unknown-key rejection + new surface only with a protocol change.
- Namespacing: row
sourcenames, storage keys, status segments, and log sources are plugin-id-scoped host-side. - Content-free logging.
- Normativity order:
protocol.json+ the SDK's conformance tests > this prose > any implementation.
Debugging is a first-class feature of this transport: run any plugin binary
in a terminal and type NDJSON at it — no host required. The SDK's
WireHarness does the same in-process from cargo test.