status, not on the presence of result (protocol).
denied names the capability, not the method. A refused presence.clear comes back as "capability":"presence.set" and a refused preferences.get as "capability":"settings.read".
The examples use the call helper that every shipped example plugin defines. plugins/flag-inspector/main.ts is the shortest to read.
Notifications: notify.send
Returns
null.
org.freedesktop.portal.Notification.AddNotification rather than org.freedesktop.Notifications, so it costs nothing in the Flatpak manifest: a portal interface is reachable from the sandbox with no --talk-name entry.
You cannot withdraw or update a notification. Cordial picks the portal id (
cordial-1, cordial-2, …) and does not tell you.
Discord presence: presence.set and presence.clear
plugins/discord-presence/main.ts is the working example. presence.set takes a closed object: any field not in this table refuses the whole call.
Returns
null. Cordial assembles Discord’s timestamps and assets sub-objects itself from the flat fields. A key that nothing has resolved renders as no picture. A plugin cannot set a button link: Cordial builds roblox://experiences/start?placeId=…&gameInstanceId=… itself, offers the join button only when a job_id is present, and falls back to the game page.
presence.clear takes no parameters and returns null. If nothing has been set in this plugin’s run it answers ok immediately and opens no connection. One connection is held for as long as the plugin runs, and a different client_id drops it and opens a new one.
The payload is a closed struct rather than a JSON value forwarded verbatim, which is the whole reason this is brokered (ADR-007).
Opening a web page: url.open
Returns
null. The scheme is checked before the D-Bus connection is touched, so a refusal never depends on a session bus. The check is strict about :// and case-insensitive: HTTPS://example.com is accepted, http:example.com is not. Without it the capability would be a way to open file:// paths or hijack a handler for an arbitrary scheme.
Session state: state.get
Capability state.read. No parameters. Returns what Cordial knows about the current game, kept from the engine’s own log so plugins do not each fold the event stream into a private copy. Every key is optional, and an absent key means not known.
Outside a game the result is
{}, and leaving a game clears every key. There is no username, display name or server location: a name needs a request to users.roblox.com and a location needs a geo-IP lookup, both third-party calls made on the player’s behalf. A plugin that starts mid-session can read this once and follow events afterwards (events). Described from the source (crates/cordial-plugins/src/state.rs), not observed in a run (INFERRED).
Settings and preferences
They are two files because
settings.set replaces wholesale, which is right for scratch state and fatal for anything a person typed. Both live in the profile, not beside your installed code, because a settings document is where a plugin records a username, a server or a webhook (ADR-013).
Settings: the document your plugin owns
settings.get takes no parameters and returns your document, {} if you have never saved anything (capability settings.read). settings.set takes {"settings": <complete new document>} and returns null (capability settings.write).
cordial/init handshake as msg.payload.settings ({} if empty, null if you were not granted settings.read).
Neither method takes a plugin id. Cordial knows which process is on the pipe. Naming another plugin in your params is not an error and is not honoured: you get your own document.
A write goes to
settings.json.new and is renamed, so a plugin killed mid-write leaves the previous document.
Preferences: the user’s answers
You declare fields inplugin.json and Cordial builds the page. There is no capability for declaring: declaring a field is how you get a page (ADR-020).
Every field takes
key and title, and optionally description and group. Fields sharing a group become one group on the page, in order of first appearance, with ungrouped fields first. In a choice, value is what lands in the document and label is prose, so renaming a label does not reset anybody’s choice.
The manifest is refused at install, by name with the key quoted, if any of these fails:
Length caps on drawable text are bytes, not codepoints, so 150 characters in a non-Latin script can be refused by a message saying “at most 200 characters”. Your words are drawn as text, never markup, and a control character anywhere refuses the whole plugin rather than being stripped.
Reading needs
settings.read and arrives as msg.payload.preferences in the handshake or from preferences.get (no parameters):
?? default and no range checks. A saved value that no longer fits your manifest falls back to the current default and Cordial logs plugin <id>: preference …. Keys nothing declares are dropped. A plugin that declares no fields and holds settings.read gets {}; null still means not granted.
There is no preferences.set. A plugin that could rewrite the answers could have the page show its choice back as the user’s. Your own state goes in settings.json. You cannot draw the page yourself: you are a separate process with no display, and one able to draw in Cordial’s window could imitate its sign-in dialog.
Asset overlays: assets.override
A plugin, or the user directly, may supply files that resolve in place of Roblox’s own for the same name. Nothing is written into the APK or into anything extracted from it, so there is no cleanup: stop consulting a root and the original resolves again (ADR-010, which reverses ADR-004).
Two routes
A shippedoverlay/ directory needs no capability. Cordial registers it at launch, before the engine reads an asset. It is the only route for a data-only plugin, and the only reliable one, because an asset served once stays cached for the rest of the process: an overlay registered after a texture loads cannot change it.
The assets.override method is for a running plugin registering a directory at runtime. Plugins start after the client is up, so this usually arrives after the engine has read a great deal. Use it to swap a root mid-session, not to ship a texture pack.
Returns
{"registered": "/absolute/path"}, or null for a clear. The path is a string for your log, not a handle.
The directory does not have to exist: registering a missing one succeeds and contributes no files. If an overlay appears to do nothing, check the path first. Your root is unregistered when your process ends, however it ends.
What resolves, and in what order
A root mirrors the APK’sassets/ layout, the same shape Sober’s asset_overlay uses. <root>/content/textures/wood.png stands in for assets/content/textures/wood.png.
Roots form a stack, lowest first: every plugin in registration order, then the user’s root last. The user’s root beats every plugin’s, and among plugins the most recently registered wins (re-registering moves you to the end). The user’s root is $XDG_CONFIG_HOME/cordial/overlay, falling back to $HOME/.config/cordial/overlay, overridable with CORDIAL_OVERLAY. Paths that would escape their root (.., an absolute name, a symlink pointing outside) are dropped when the index is built.
Gameplay-affecting substitution is possible and Cordial builds no detection for it. Replacing a collision or hitbox mesh with a smaller or absent one is an advantage, not a cosmetic change. ADR-010 leaves it to the user’s responsibility, as Sober and Bloxstrap do, and the capability’s consent text says so.
--check-overlays reports which of your files match nothing in the current build, and the shadow report names every case where two layers offered one file and which won: