Skip to main content
There are two event buses. They share the wire format and nothing else. Both arrive as a push: a line with event and payload and no id or status (protocol). crates/cordial-plugins/src/protocol.rs has a test asserting Push never grows either field. The cordial/init handshake uses the same shape. It is not an event anyone published and no capability gates it. Match on the name and ignore it.

Core events

crates/cordial-plugins/src/core_events.rs holds the whole vocabulary. Each entry is gated by one capability. A dispatcher comparing msg.event === "client.launch" never fires: the wire name is cordial/client.launch.
A plugin that waits for client.ready or window.resized waits forever. They are in the table so that adding a publisher later cannot forget the capability. Design around the others.
  • client.launch goes out immediately after start_all returns, the first moment anybody can be told. profile is the active profile’s name, not its path, and null if the path has no final component.
  • engine.version follows once the version has been read from libroblox.so. If it cannot be read nothing is published and Cordial prints plugins: engine version not readable, so cordial/engine.version is not published.
  • client.shutdown is the last thing any plugin is told and is followed by a bounded flush.
  • game.presence is what a running experience set as its Rich Presence (BloxstrapRPC lines the engine writes to its own log, read by Cordial; nothing is hooked). The payload is the merged presence so far, with keys such as details, state, start, end, large_image_key, large_text, small_image_key, small_text, place_id and job_id, and it is {} when you leave the game. Gated on presence.set rather than lifecycle.read, so a plugin allowed to know the client started does not thereby learn what you play. Described from the source, not observed in a run (INFERRED).
  • Handlers should treat a missing field as normal. client.shutdown carries null because the name is the whole information.
  • There is no network.connected. If you saw it in a list, that list was reading a negative test that proves a name absent from the table reaches nobody.
Core events carry nothing about a place, game or user except game.presence (and place_id, job_id inside it). The session snapshot with the user id is a separate call, state.get. Why: ADR-007.

The capability is the subscription

events.subscribe on a cordial/… type is refused ("cordial/client.launch" has not been declared by any plugin). Core events are never declared into the registry. Request lifecycle.read in your manifest, be granted it, and pushes are addressed to you. An event absent from the table needs a capability nobody holds and reaches nobody (plugin core event "…" is not in the capability table, so nobody receives it). lifecycle.subscribe returns {"status":"ok","id":N,"result":null} and records nothing. It confirms you hold lifecycle.read, which a push cannot do: silence cannot distinguish “not granted” from “nothing has happened yet”. A plugin that never calls it hears the same events. A plugin may not declare under cordial: "cordial" is reserved for Cordial's own events; a plugin may not declare under it. So no plugin can mint a convincing cordial/… event.

Observed, never vetoed

Core events are observed and cannot be vetoed, delayed or altered (ADR-026). This is structural: delivery is a push with no id, so there is nothing to answer on, and publish_core never reads the plugin’s stdout. Publishing is a try_send onto a per-plugin queue and never blocks the thread publishing a platform event. Why a structural absence is a stronger guarantee than an ignored return value: ADR-026.

A slow subscriber misses events

The loss is counted, not silent. Write a plugin that expects to miss things. One needing exactly-once delivery should ask Cordial for state instead.
The repository has two plugin hosts. crates/cordial-runtime/src/plugin_host.rs is the one cordial-run starts and the one whose messages and limits this page quotes. cordial_plugins::host::Session has the same shape and is constructed only in that crate’s tests.

Plugin-declared events

Unlike the core bus, this one is wired up in the host the client runs. You supply a bare name and Cordial supplies the namespace, from its own record of which process is on the pipe. Take type from the response rather than assembling it. A name containing a slash gives your-id/a/b and still cannot escape your prefix. Re-declaring your own type is not a conflict, so a restarting plugin lands where it was.

Start order and dependencies

Nothing orders start-up so that declarations happen first. dependencies does not affect start order. plugin_host::start_all iterates plugins sorted by directory name and never consults dependencies. The dependency planner in crates/cordial-plugins/src/resolve.rs decides what gets installed, never what starts first. Nothing refuses to start a plugin whose declared dependency is absent either, so ADR-006’s “must surface as a named error to the dependent” is also unimplemented. Two things work:
  • Retry. Treat has not been declared by any plugin as “not yet” and try again later.
  • Subscribe late, at the point you first need the events.
Still list a real dependency: the installer reads it, so it is how the plugin gets onto the machine.

Delivery between plugins is not lossy

events.publish writes to each subscriber with a blocking write_all into its ChildStdin with no timeout. Nothing is dropped and nothing counted. A subscriber that has stopped reading can fill the pipe and the publisher’s events.publish is what waits. A subscriber that has died costs the publisher nothing. INFERRED from the code path: nobody has produced a wedged subscriber and measured it.

Worked example: a plugin on both buses

Listens for the client launching, announces its own event, and subscribes to another plugin’s. flag-manager is a stand-in; no plugin of that name exists in this repository. plugin.json:
main.ts: