cat, and needs no shared memory, which would be the first step back toward the in-process access ADR-003 rules out.
Messages
A request isid, method and optionally params. id is yours to allocate and must be unique among your calls in flight. Omitting params is legal.
status and the id it answers, in one of three shapes:
denied is not an error on purpose: “I was not allowed” and “it went wrong” send an author to different places. Check status before result, and report capability when you stop. Denied, needs flags.write tells the user how to fix it.
A push is a line Cordial sends that answers nothing. It carries event and payload and neither id nor status, which is how your dispatcher tells it from a reply:
crates/cordial-runtime/src/plugin_host.rs: the cordial/init handshake, another plugin’s events.publish forwarded to its subscribers, and a core event offered to every plugin holding the capability that gates it. cordial/ is a reserved prefix, so a line arriving with it is one Cordial published.
The handshake
The first push is alwayscordial/init, before you have asked for anything. No capability gates it and it is not an event anyone published, so match on the name and ignore anything you do not handle.
null means ask for the capability; {} means you are new here. The handshake does not list your grants. You learn what you hold by calling and reading the status.
A worked exchange
A plugin grantedflags.read and log but not flags.write. ← is Cordial to the plugin.
null because settings.read was not granted. The last call is refused before its parameters are read: authorisation happens ahead of dispatch.
plugins/flag-inspector/ is the shipped example of this shape. It asks for flags.read and deliberately not flags.write, so the denial is visible in a real run. crates/cordial-plugins/tests/flag_inspector.rs drives it against a real broker and skips itself when deno is not installed, so a green run is not proof it executed.
Two mistakes to design against
A dispatcher that does not separate a push from a reply discards every event. A read loop that passes every line topending.get(msg.id) does pending.get(undefined) for a push, which never matches and never throws. The event is dropped in silence. Branch on msg.id === undefined first, as the plugin above does and as crates/cordial-plugins/tests/fixtures/settings.ts and events_subscriber.ts do. fixtures/roundtrip.ts reads one line per call and is a request/response test, not a dispatcher.
console.log ends the session. See the warning on getting started.
Two smaller shapes to check when a call quietly does nothing:
settings.gettakes no parameters at all: Cordial knows which process is on the pipe.flags.setwants{"values": {…}}. Hand it{key, value}and you getflags.set needs a values object, which is anerror, so a plugin checking only for denials reads it as success.
The sandbox
A plugin gets no Deno permissions. Cordial runsdeno run --no-prompt --quiet <entry> with no --allow-* flag of any kind, and a test fails if one ever appears. There is no file, network, environment or subprocess access. The only thing a plugin can reach is the pipe.
--no-prompt matters: without it Deno would ask for a permission on first use and nothing would answer. With it, touching the filesystem throws immediately and you can catch and report it.
Cordial prints which layers you actually got, at spawn, every time:
flatpak-spawn --sandbox needs --talk-name=org.freedesktop.Flatpak, which also grants --host, arbitrary command execution outside the sandbox (ADR-018). Without bwrap a plugin still has zero Deno permissions and still reaches nothing except through the broker.
Do not rely on the OS layer hiding your home. The sandbox binds
deno and the prefix holding its libraries read-only: the parent of Cellar for Homebrew, otherwise the parent of the bin directory the binary sits in. For a distribution package that is /usr. For ~/.deno/bin/deno it binds ~/.deno, and for ~/.local/bin/deno it binds ~/.local, which contains ~/.local/share/cordial/profiles/. Nothing caps the bind to outside $HOME. The Deno permission layer is what stops a plugin opening any of it.deno must be on PATH and Cordial packages none. A missing interpreter shows as plugin hello: could not start (…) and the client carries on without you.
How a plugin starts
The client process (cordial-run) starts plugins, not the shell, and not until the engine is up, so a misbehaving plugin cannot interfere with bring-up. For each directory under the plugin root, in sorted order, the first check that stops it prints:
After all plugins are considered the client prints
N plugin(s) running, but only if at least one did. An absent line is not evidence that discovery did not run.
- Enablement. Absence from
plugin-enabled.jsonmeans enabled, with two exceptions. First-party plugins may ship switched off (currentlyfps-flex). And the key*is a master switch:{"*": false}disables every plugin whatever its own row says. If a plugin will not start and has no row, look for that key first. - Roots. Discovery and spawning read both plugin roots, system first. All four first-party plugins (
flag-inspector,discord-presence,fps-flex,hide-gui) start that way. For your own plugin the user root is the easier place to iterate. - Ids. Unique across the whole root. If two directories claim one, the first in sorted order wins and the second is reported and skipped, because grants, event namespaces and settings directories are all keyed by id.
- Threads. Each running plugin gets a thread of its own, blocking on its own stdout.
- Grants file is the authority, not your manifest. Settings only offers switches for capabilities you requested (capabilities).
- No restart needed. A running client polls the profile’s plugin roots,
plugin-enabled.jsonandplugin-grants.json, and starts, stops or restarts exactly the plugin that changed (ADR-038). A restart after an update that changes your manifest’scapabilitiesapplies the intersection of what the profile granted and what the new manifest requests.