Writing a RelayFabric plugin¶
A plugin is a subprocess the daemon spawns and speaks Plugin Protocol v1 to over a Unix domain socket (spec §9, §84): a 4-byte big-endian length prefix plus a CBOR body. This doc names the SDKs and walks the wire lifecycle so you can write a new plugin — in Python, Rust, or any other language — without reverse-engineering the fleet.
Rust SDK: relay-core + relay-ipc¶
Spec §92 asks for an SDK containing message types, endpoint types, an IPC
client, and capability definitions. In this repo, crates/relay-core and
crates/relay-ipc already are that SDK — relay-core owns
Capabilities and the shared message/endpoint types, relay-ipc owns the
frame codec (PluginToDaemon/DaemonToPlugin, read_frame/write_frame)
and is what plugins/mqtt (the one in-tree Rust plugin) links against
directly. There is no separate facade crate to wrap them in — a Rust plugin
just depends on both crates from the workspace.
Python SDK: relayfabric_sdk¶
sdk/python/relayfabric_sdk/ is the Python equivalent: ipc.py (frame
codec + frame builders), cache.py (SentCache, a loop-guard for
echoed-back sends), harness.py (FakeSock, a scripted duplex socket for
tests), and runner.py (run_plugin, the shared main-loop scaffold below).
Consume it via a sys.path insert to ../../sdk/python, exactly like the
in-tree Python plugins (lxmf, signal, meshtastic, meshcore) do — no install
required. pip install -e sdk/python also works if you prefer that.
Lifecycle¶
1. Env contract¶
The daemon spawns your process with:
RELAYFABRIC_SOCKET— path to the Unix domain socket to connect to.RELAYFABRIC_PLUGIN_NAME— the name this plugin is configured under.RELAYFABRIC_PLUGIN_CONFIG— the plugin'sconfig:block, as JSON.
Missing RELAYFABRIC_SOCKET is a startup misconfiguration; a Python plugin
using run_plugin exits 2 for it (see below).
2. Hello / HelloAck¶
Connect, then send Hello { plugin, version, protocol_version, capabilities }
and read one HelloAck { protocol_version, error } frame before doing
anything else. A non-null error (or any other frame type) means the
daemon rejected you — log it and exit; do not proceed to the read loop.
3. Frames (the t tag)¶
t |
Direction | Purpose |
|---|---|---|
hello |
plugin → daemon | capability announcement, once at startup |
hello_ack |
daemon → plugin | accept/reject the hello |
inbound |
plugin → daemon | a message received from the native network |
send |
daemon → plugin | deliver to a configured endpoint |
send_direct |
daemon → plugin | deliver to a native ref, no endpoint mapping (requires direct_messages) |
delivery_result |
plugin → daemon | outcome of a send/send_direct, correlated by corr |
shutdown |
daemon → plugin | clean-exit request |
An unrecognized t in either direction MUST be ignored, not treated as an
error — this is how new frame variants can be added later without breaking
plugins/daemons that don't know about them yet.
4. DeliveryResult semantics¶
delivered: true is terminal — the daemon marks the delivery done and never
retries it. delivered: false goes back into the retry/backoff path;
detail is a free-text diagnostic string for logs/tracing, never parsed.
For a fan-out send (e.g. lxmf's channel members), report at-least-one
semantics: delivered: true as soon as any recipient succeeds, with the
failures named in detail. Always echo the corr from the triggering
send/send_direct frame unchanged.
5. Capability flags¶
text, direct_messages, groups, attachments, location, reactions,
receipts, presence, max_payload (spec §16). Advertise only what you
actually implement — the daemon uses these to route and to gate features,
not just to display them. direct_messages in particular gates
send_direct: only plugins that advertise it ever receive that frame (used
today for identity-link challenge delivery, a single one-shot send to a
native ref outside any channel/endpoint mapping). max_payload (bytes, or
null for unbounded) lets the daemon truncate before it ever reaches you.
The Python runner: relayfabric_sdk.run_plugin¶
run_plugin(plugin_name, version, bridge_factory, capabilities, *,
socket_env="RELAYFABRIC_SOCKET", config_env="RELAYFABRIC_PLUGIN_CONFIG") implements
steps 1–3 above so a plugin's main() doesn't hand-roll them: it reads the
env contract, does the Hello/HelloAck handshake, calls
bridge_factory(cfg_dict, sock) -> bridge, calls bridge.start() if
present, then dispatches send/send_direct/shutdown to
bridge.handle_send(frame) / bridge.handle_send_direct(frame) (skipped
if absent) / bridge.stop() + exit 0. An IO error mid-loop exits 1.
Adoption is opt-in: it fits cleanly when a plugin's Hello capabilities don't
depend on its (validated) config. When they do — e.g. a max_payload derived
from a config field with a default — the plugin keeps its own main()
rather than duplicating that default outside the SDK's single validation
point; see the plugin's own source comments for the specific reason.
Golden frames, for new-language authors¶
The wire format is locked byte-for-byte across implementations. Before trusting a new codec, reproduce these exact hex vectors:
- Rust:
crates/relay-ipc/src/lib.rs—canonical_hello_frame_bytes_are_stable,canonical_inbound_attachment_frame_bytes_are_stable. - Python:
sdk/python/tests/test_ipc.py—CANONICAL_HELLO_HEX,CANONICAL_INBOUND_ATTACHMENT_HEX.
Dict/struct key order matters for the byte-lock (CBOR maps are encoded in
field-declaration order, not sorted) — match the field order in
relay-ipc's PluginToDaemon/DaemonToPlugin enums exactly.