Expand description
§Working together
Plugins share state and talk through four channels on the account, and a UI plugin works with the daemon half of itself, its companion. This page says which to use, and how a UI plugin that runs in several viewers at once stays correct.
The worked example is a pair: together_ui.rs
and together_companion.rs
(standard-plugin new <id> --kind companion scaffolds it).
§Four channels
| Channel | SDK | Stored | Delivered as | Use it for |
|---|---|---|---|---|
| Values | values() | Yes, durable, per account | Event::ValueChanged after values().watch(prefix) | State: the latest build status, settings a plugin learned, anything a viewer opened later must see |
| Live | live() | No, latest value only | Event::Live after live().subscribe(prefix); Event::LiveDeleted when the publisher withdraws a key (live().delete(key)) | Fast-changing readings where only the newest matters: a progress figure, a sensor reading. At most 16 KiB each: publish one key per item (a build, a pane) and only the items that changed, rather than one snapshot of everything. Withdraw a key that no longer applies (a pane that closed) rather than publishing null |
| Events | events() | No | Event::Plugin after events().on(pattern) | Something happened: “the status changed”, “a build started”. Listeners that are not running miss it |
| Calls | calls() | No | The companion’s DaemonPlugin::call | Asking the companion to do something, and getting an answer |
Keys, live keys and event names are namespaced by plugin id: a plugin uses
<id>.* freely; global.*, another plugin’s namespace and system.* need
grants (grants). Payloads are JSON; the SDK serializes typed
values with serde.
A common shape combines two: the writer stores the state as a value and emits an event saying it changed; readers read the value when they start and re-read it on the event. A viewer opened later still sees the state, and nobody polls.
§Writes before the account connection opens
A plugin can start before its account connection opens (a daemon
plugin does after every daemon start), and writes made then fail. Treat a
failed values().set or live().publish as not written: remember the
last payload that succeeded, and publish again on later events (an
account change, the interest, the next reading). Make the publish
idempotent: it builds the payload, compares it with the last one written,
and writes only when they differ. issue-counts’s daemon half does this
(agent guide).
§Companions
A companion is a daemon plugin (manifest kind companion) with the same id
as a UI plugin. The UI half runs in every viewer and shows things; the
companion runs in standardd and does things:
-
It holds the grants a UI plugin should not:
process.exec:, file access, panes, HTTP. -
It does work once. The UI half asks (grant
call:<id>); the companion answers inDaemonPlugin::call, and starts longer work as a task (cx.spawn) that runs when the host next drives it. The UI half asks in one of three ways:SDK Waits Answer calls().send(method, request, target)No None: the outcome arrives another way (a value, a live message) calls().call_async(method, request, target)No Event::CallResult { id, result }with the returnedCallId, after 10 s at most (call_async_timeoutfor another bound, 30 s at most)calls().call(method, request, target)Yes, 10 s at most ( call_json_timeout, 30 s at most)The return value; a timeout is Error::Unavailable("call_timeout")Prefer
sendandcall_asyncin a UI plugin:eventandframekeep their budgets and the plugin keeps painting while the companion works.callblocks the plugin’s thread (never the viewer’s), and in a browser without JSPI it answersunavailable;sendandcall_asyncwork in every browser. At most 32 sends and async calls of one plugin are in flight at once; past that they answerError::RateLimited. -
It publishes results as values and events the UI halves read.
-
It reads outside sources only while a viewer shows the UI (
Context::interest(), daemon plugins). The UI half never polls it: no timer, heartbeat or repeatedsendto keep it reading. A call goes out for what the user did; everything else arrives as values, live messages and events.
Target picks which daemon answers or hears:
| Target | Reaches |
|---|---|
Target::Singleton | The plugin’s singleton daemon instance: one per account, for work that must happen once |
Target::Machine(id) | The daemon on one machine (work that belongs to that machine’s files or panes) |
Target::Daemons | Every daemon (fleet-wide events) |
Target::Viewers | Every viewer (a companion announcing a change to the UI halves) |
Target::All | Every listener |
A daemon knows its own machine (Context::machine_id()), so a companion
that publishes per machine keys its values and live messages by it
(<id>.status.<machine>) and the UI half matches them against
account::state().machines. A viewer knows its own machine too
(view::machine_id(), none in a browser) and its instance
(view::instance_id(), one per running viewer).
A daemon plugin enabled on several machines runs one instance per machine (the fleet); the singleton is the one instance the account elects for account-wide work, on the machine that holds its lease (daemon plugins).
§Running in several viewers
The same account can be open in several viewers at once: a laptop, a desktop, a browser tab. Each runs its own instance of every UI plugin, so a UI plugin must be side-effect free: painting, reading, and listening are fine; anything that changes the account or the world must not simply run in every instance.
Three tools keep it correct:
view::is_driving()is true in the viewer the user is controlling. User-initiated effects (a click that starts a build) run only there;Event::Drivingreports changes.claims().claim(key, ttl_ms)(orclaims().once(key, ttl_ms, effect)) makes an effect exactly-once across instances: only the instance whose claim succeeds performs it. Use it even behindis_driving(), since two viewers can briefly both believe they drive. Keys live in the plugin’s own namespace; no grant.- A companion for everything else: effects that are not user-initiated (reacting to an event, a schedule) belong in a daemon, which runs once.
The double-play rule is the check for getting this wrong: a UI plugin
that listens to shared events and holds a grant that acts on the account
(fetch:, values.write:, events.emit:, call:) would perform the
action once per viewer per event. standard-plugin check warns about the
combination (grants).
The account keeps a claim for seven days, first writer wins, whatever
ttl_ms asks; a later claim of the same key from the viewer machine that
holds it renews it (true). release changes nothing on the account yet.