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_start hooks 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; any ok: false initialize reply is
terminal and parks the plugin in failed (no auto-restart; file watchers
stay armed so a rebuilt binary recovers). A repeated initialize on 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
within startup → teardown + backoff restart. Immediately after the
reply, the host delivers one core:apps.changed event (to plugins whose
listen matches) carrying the full running-applications snapshot.ping — {} → {"ok": true}. See Liveness.event (notification) — {name, payload}, filtered by manifest listen
globs: 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, among protocol.json host_events.ax_notifications; every
keystroke posts AXValueChanged, 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), and
core:session.opened (the flashlight opened; advisory — eager plugins may
refresh, nothing is required). protocol.json host_events pins the list.
core:network.changed and core:volumes.changed carry 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’s listen matches 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 whose sources[] declare
live: true, dispatched per flashlight keystroke but ONLY when the query
is explicitly scoped to the source (@source filter or a bang’s source)
— 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.
live cannot combine with kind: "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 role AXLink for native-style semantic links (f
plain; F Command-Shift), or
FlashTerminalLink for links inside terminal content (f Shift, F
Command-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 nested frame is 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_id is 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_pid raises that app and records the jump
in movement history; message shows as a toast). unhandled = “not my
context” — the host MAY fall back (e.g. to a keystroke mapping). An
error = “mine, but it broke” — the host MUST NOT fall back
(double-fire protection); no reply / timeout / crash coerces to this case.
Bang dispatch arrives as kind: "command" with the bang token as
subcommand. Unknown kind → error. The host never dispatches perform
to a plugin in failed/unspawnable state — it settles as unhandled
without 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"]}}}
segments is required: the complete set of this plugin’s
observed segments as bare manifest status names (no
flash.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.#{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.listen like 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
the core:apps.changed snapshot), 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.segments is 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.
The status_observed group in fixtures/wire-values.fixture pins 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 its
perform and 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),
draws prefix still 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
delivers event {"name": "core:poll:<name>"} when one is due, bypassing
manifest listen because 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). priority is
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’s
system priority is core-only. Names are [a-z0-9_-]{1,64}; every is
floored at 0.05 s, both are capped at 86400 s, and a set holds at most 64
registrations (protocol.json poll). 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