RIM plugin protocol v1
RIM plugin protocol v1version 1status: frozen, normativerim-server/docs/specs/plugin-protocol-v1.mdcommit 98d1883
Status: frozen, normative. This is the contract between the RIM desktop host and a plugin process. It is written for someone building a plugin from scratch, in any language, without an SDK.
The machine-readable source of truth is the JSON Schema
protocol/plugin-protocol-v1.schema.json
in rim-ui. Every frame described here is a $defs entry there, with an example, and the host's
conformance tests (rim-ui/src-tauri/src/plugin_host/protocol/conformance.rs) check those
examples against the frames the host really builds. If this document and the schema ever
disagree, the schema wins, and this document has a bug.
The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
Two later documents build on this one without changing it: web-plugins-v1.md (the
WebSandboxed kind: the same messages over a sandboxed iframe's MessagePort) and
workspace-plugins-v1.md (per-workspace registries and manifests signed by a publisher's key).
Contents #
- The protocol in one minute
- Process and transport
- Framing
- Message envelope
- Request and response ids
- Connection lifecycle
- Handshake and version negotiation
- Errors
- Running commands
- The dangerous-command confirmation gate
- People: principals
- Capabilities
- Heartbeats
- Logging
- Shutdown and app quit
- Compatibility and versioning
- Method reference: what exists and what does not
- Worked example: one whole session
- A complete plugin in Python
- Plugin author checklist
1. The protocol in one minute #
- The host starts your plugin as a child process and talks to it over stdin and stdout. stderr is yours for free-form logging.
- Every message is a frame: a 4-byte little-endian unsigned length, then that many bytes of UTF-8 JSON. Each JSON document is one JSON-RPC 2.0 message.
- The host's first request is
rim.handshake. You answer which protocol version you speak. - The host runs your commands with
rim.command.executeand pings you withrim.heartbeatevery 30 seconds. - The host tells you who is signed in with a
Principal({ principalId, displayName }), both in the handshake and inrim.principal.changedwhen that changes. - You may write to the host log with
rim.log. - When the host sends
rim.shutdownor closes your stdin, you exit.
That is all of frozen v1. Everything else a plugin can do today is listed, with its real status, in section 17.
2. Process and transport #
A plugin is a directory with a manifest (rim-plugin.json) and an entrypoint file. The manifest
format is defined by the host (rim-ui/src-tauri/src/plugin_host/manifest.rs, and comment-header
"simple mode" in simple_manifest.rs), not by this protocol; this document only refers to the
manifest fields the protocol depends on. Section 19 shows a minimal manifest.
A plugin id (plugin_id in the manifest) is reverse-domain: at least three dot-separated
segments of ASCII letters, digits and hyphens, such as dev.example.hello-world. Underscores are
not allowed. Ids starting with dev.rim. or com.rim. are reserved.
The host starts the plugin like this:
- It executes
<plugin dir>/<entrypoint>directly, with no interpreter, no arguments, the plugin directory as the working directory and the user's environment. On Unix the entrypoint must be executable; a script needs a shebang line such as#!/usr/bin/env python3. - stdin is a pipe from the host to the plugin. stdout is a pipe from the plugin to the host.
Nothing but frames may be written to stdout: a stray
printcorrupts the stream. - stderr is inherited by the host process and is not part of the protocol.
- One process is one connection. The host starts a plugin when the user enables it and, at app start, every plugin that was enabled when the app last ran. Start-up is not lazy in v1.
3. Framing #
+----------------+-----------------------------------------+
| length: u32 LE | payload: `length` bytes of UTF-8 JSON |
+----------------+-----------------------------------------+
- The length prefix is 4 bytes, unsigned, little-endian. Earlier RIM docs said big-endian; that was wrong, and the host has always used little-endian.
- The length counts payload bytes only, not the 4 prefix bytes.
- The payload is exactly one JSON-RPC 2.0 message encoded as UTF-8 JSON. There is no newline delimiter, no batching (JSON arrays are not valid frames) and no MessagePack in v1.
- A payload is at most 4,194,304 bytes (4 MiB), in both directions. A frame that announces a larger length ends the connection: the host stops reading and the plugin is stopped. Keep results small; page large data yourself.
- A frame whose payload is not valid JSON, or not a valid JSON-RPC message, is logged and dropped.
The connection survives, because the length prefix kept the framing intact. Nobody answers it,
so neither side ever has to handle a response with a
nullid.
For example, the notification
{"jsonrpc":"2.0","method":"rim.log","params":{"level":"info","message":"started"}} is 82 bytes
long, so its frame starts with the bytes 52 00 00 00.
Reading a frame in Python:
header = sys.stdin.buffer.read(4) # b"" means the host closed stdin: exit
(length,) = struct.unpack("<I", header) # "<I" = little-endian u32
message = json.loads(sys.stdin.buffer.read(length))
Reads from a pipe can return fewer bytes than asked for. Loop until you have all 4 header bytes
and all length payload bytes; treat end-of-file in the middle of a frame as a closed connection.
4. Message envelope #
v1 uses plain JSON-RPC 2.0. Three shapes travel in both directions:
| Shape | Fields | Answered? |
|---|---|---|
| Request | jsonrpc: "2.0", id, method, params (optional) |
Yes, exactly once, with a response carrying the same id |
| Response | jsonrpc: "2.0", id, and exactly one of result or error |
No |
| Notification | jsonrpc: "2.0", method, params (optional), no id |
No |
How a receiver tells them apart, which is exactly what the host does:
- has
methodand a non-nullid: a request; - has
methodand noid(orid: null): a notification; - has
idand nomethod: a response; - anything else: invalid, logged and dropped.
Further rules:
- Field names in params and results are camelCase (
protocolVersion,commandId,principalId). - Requests can be in flight in both directions at once. The host answers your requests while one of its own requests to you is pending, and you MUST be able to do the same, or at least never deadlock waiting for one response while another frame arrives first.
- Responses may arrive in any order. Match them by
id, never by position. - Senders emit exactly the frozen shapes. Receivers ignore fields they do not know rather than fail, so one side's bug does not break the connection. The host does.
- An unknown request is answered with
METHOD_NOT_FOUND(-32601). An unknown notification is ignored. This is what lets v1 grow new methods without breaking anyone (see section 16).
5. Request and response ids #
- An
idis an integer or a string, nevernull. The answering side echoes it unchanged, with the same JSON type (2and"2"are different ids). - The host numbers its requests
1, 2, 3, ...per connection, so the handshake is normally id1. It numbers heartbeats-1, -2, -3, ..., so a negative integer id from the host is always a heartbeat. - A plugin MAY number its own requests however it likes (integers,
"log-1", UUIDs). The two directions are independent: your ids only need to be unique among your own pending requests. - The host answers every request you send, including denials, so a plugin never has to time out on the host. The host does time out on plugins (10 seconds for the handshake, 5 for a heartbeat, 30 for a command); a late answer to a request the host gave up on is dropped.
6. Connection lifecycle #
host plugin
| spawn entrypoint |
|------------------ rim.handshake (id 1) ---------->|
|<----------- rim.log (optional, any number) -------|
|<------------- handshake result (id 1) ------------| accepted: true
| |
|------- rim.command.execute (id 2, 3, ...) ------->| whenever the user or agent runs a command
|<-------------- result or error -------------------|
|------- rim.heartbeat (id -1, -2, ...) ----------->| every 30 s
|<-------------------- {} --------------------------|
|------- rim.principal.changed (notification) ----->| on sign-in, sign-out, account switch
|<--------------- rim.log (any time) ---------------|
| |
|------------- rim.shutdown (notification) -------->|
| close stdin | plugin exits
- Spawn. The host starts the process (section 2).
- Handshake. The host's first frame is
rim.handshake. Until you have answered it, send nothing butrim.log. The host waits up to 10 seconds for the answer; a plugin that does not answer in time, refuses, or answers wrongly is stopped (section 7). - Running. The host sends commands, heartbeats and principal changes; the plugin answers and may log. A plugin with nothing to do just waits on stdin.
- Stopping. The host sends
rim.shutdown, then closes stdin. The plugin exits (section 15). A plugin MUST also exit when stdin reaches end-of-file without arim.shutdown, because that is how a host that crashed says goodbye.
If the plugin exits or crashes on its own, the connection ends, commands for it fail with "plugin process is not running", and the user can start it again by re-enabling it. The host does not restart plugins automatically in v1.
7. Handshake and version negotiation #
The handshake has the same shape in every protocol version, so any host can negotiate with any plugin, including one from the future.
Request: rim.handshake (host to plugin) #
| Param | Type | Meaning |
|---|---|---|
protocolVersion |
integer ≥ 1 | The version the host prefers: the first entry of supportedVersions. |
supportedVersions |
array of integers, newest first, at least one | Every protocol version the host speaks. Today [1]. |
pluginId |
string | The plugin id from the manifest the host loaded for this process. |
hostVersion |
string | The RIM app version, for logs and diagnostics. Do not branch on it; branch on the protocol version. |
grantedCapabilities |
array of strings | Capabilities the user approved for this plugin (section 12). |
principal |
Principal or null |
The signed-in user the plugin acts for, or null when nobody is signed in yet (section 11). |
{
"jsonrpc": "2.0",
"id": 1,
"method": "rim.handshake",
"params": {
"protocolVersion": 1,
"supportedVersions": [1],
"pluginId": "dev.example.hello-world",
"hostVersion": "0.1.0",
"grantedCapabilities": [],
"principal": {
"principalId": "rimp0_PWcag22GowE59KogGTaUReeoF_q4E5EVi28q83uaGBU",
"displayName": "Jakub"
}
}
}
Response (plugin to host) #
| Result field | Type | Meaning |
|---|---|---|
accepted |
boolean | Whether the plugin accepts the connection. |
protocolVersion |
integer ≥ 1 | The version the plugin chose. MUST be one of supportedVersions. |
pluginId |
string | The plugin's own id. MUST equal params.pluginId. |
error |
string or null, optional |
Why the plugin refused, when accepted is false. |
{
"jsonrpc": "2.0",
"id": 1,
"result": { "accepted": true, "protocolVersion": 1, "pluginId": "dev.example.hello-world" }
}
Negotiation rules #
The plugin picks. It answers with one version from
supportedVersions, normally the newest one it speaks. Every frame after the handshake follows the chosen version.A plugin that speaks none of the offered versions answers
accepted: false, setsprotocolVersionto the version it does speak, and says why inerror. The host logs the reason and stops the plugin, so a plugin that refuses SHOULD exit right after sending its answer. For example:{ "jsonrpc": "2.0", "id": 1, "result": { "accepted": false, "protocolVersion": 2, "pluginId": "dev.example.hello-world", "error": "host speaks protocol [1], this plugin needs 2" } }A plugin that refuses for any other reason (a
pluginIdit does not recognise, a missing capability it cannot work without) answers the same way, withaccepted: falseanderror.The host also stops the plugin when the answer picks a version it did not offer, when
pluginIddiffers from the one it sent, when the result does not have the shape above, or when the answer is a JSON-RPCerrorinstead of aresult.After a failed handshake the host sends no
rim.shutdownand does not close stdin first; it goes straight to steps 3 to 5 of section 15 (one second to exit, thenSIGTERM, then a kill).The handshake is the host's first request and happens once per connection. There is no re-negotiation; a plugin that wants another version is restarted.
8. Errors #
A failed request is answered with a JSON-RPC error response:
{ "jsonrpc": "2.0", "id": 2, "error": { "code": -32002, "message": "kubectl failed: context not found" } }
| Field | Type | Meaning |
|---|---|---|
code |
integer | One of the codes below, or another integer the plugin documents. |
message |
string | One line, human-readable. The host may show it to the user, so write it for a person. |
data |
any JSON, optional | Machine-readable details. |
The codes v1 defines:
| Code | Name | Meaning | Typically sent by |
|---|---|---|---|
-32700 |
PARSE_ERROR |
The frame is not valid JSON. | Reserved: the host drops such frames and does not answer. |
-32600 |
INVALID_REQUEST |
The JSON is not a valid JSON-RPC 2.0 message. | Reserved, as above. |
-32601 |
METHOD_NOT_FOUND |
The receiver does not implement the method. | Both sides, for any unknown request. |
-32602 |
INVALID_PARAMS |
Params are missing or have the wrong shape, including an unknown commandId. |
Both sides. |
-32603 |
INTERNAL_ERROR |
The receiver failed while handling a valid request. | Both sides. |
-32000 |
CAPABILITY_DENIED |
The user has not approved the capability the method needs. | Host. |
-32001 |
RATE_LIMITED |
The plugin called a capability more often than its per-minute limit. | Host. |
-32002 |
PLUGIN_ERROR |
The plugin ran the command and it failed; message says why. |
Plugin, for a failed command. |
JSON-RPC 2.0 reserves -32768 to -32000, and RIM uses only the codes above from that range. A
plugin's own error codes SHOULD lie outside it. A new protocol error code can be added within v1
only after it is documented in the schema (section 16).
How the host surfaces a plugin's error: when your answer to rim.command.execute is an error,
the command fails for the user with your message, and your code and data are passed along
to the UI unchanged.
9. Running commands #
A plugin's commands are declared in its manifest. The fields this protocol depends on:
| Manifest field | Effect on the protocol |
|---|---|
id |
The commandId the host sends. The UI and keybinds address the command as plugin.<pluginId>.<id>; the plugin only ever sees <id>. |
dangerous |
Turns on the confirmation gate (section 10). |
required_capabilities |
The host refuses to run the command unless every listed capability is approved (section 12). |
ai_callable |
The AI agent may run the command, after the user consented to this plugin's AI commands. |
arguments |
Describes the args object for the UI and the agent. The host does not validate args against it; the plugin MUST validate its own input. |
Request: rim.command.execute (host to plugin) #
| Param | Type | Meaning |
|---|---|---|
commandId |
string | The id of a command in the plugin's manifest. |
args |
object | The command's arguments, keyed by argument name. {} when there are none; never null, never an array. |
invoker |
Principal or null |
The signed-in user on whose behalf the command runs, or null when nobody is signed in. |
initiator |
"user" or "agent" |
user: a person ran it from the palette, a keybind, a panel or a context action. agent: the AI agent ran it, after the user consented to this plugin's AI commands. |
confirmed |
boolean | true only for a dangerous command the user confirmed for this run. Always false for a command that is not dangerous. |
{
"jsonrpc": "2.0",
"id": 2,
"method": "rim.command.execute",
"params": {
"commandId": "greet",
"args": { "name": "RIM" },
"invoker": { "principalId": "rimp0_PWcag22GowE59KogGTaUReeoF_q4E5EVi28q83uaGBU", "displayName": "Jakub" },
"initiator": "user",
"confirmed": false
}
}
Response (plugin to host) #
On success the result is an object:
| Result field | Type | Meaning |
|---|---|---|
payload |
any JSON, optional | The command's output. Missing means null. |
message |
string, optional | A one-line summary the host may show to the user. |
{ "jsonrpc": "2.0", "id": 2, "result": { "payload": "Hello, RIM!" } }
On failure, answer with an error response instead (section 8): normally PLUGIN_ERROR
(-32002) when the command ran and failed, and INVALID_PARAMS (-32602) for an unknown
commandId or bad args.
Rules:
- The host gives up on a command after 30 seconds and reports a timeout to the user. A command that takes longer should answer early with what it started and report progress some other way; v1 has no progress or cancellation messages.
- A
resultthat is not an object fails the command with "invalid command result", even if it is valid JSON. - The host only sends
commandIds that the manifest declares, but a plugin MUST still answer an unknown one withINVALID_PARAMS, never by crashing. - Several commands can be in flight at once. A plugin MAY handle them one at a time; the host
matches answers by
id.
10. The dangerous-command confirmation gate #
A command whose manifest entry has "dangerous": true (restarting a pod, deleting a branch,
sending money) runs only after the user confirmed that run. The gate works like this:
- The host refuses an unconfirmed dangerous command before anything reaches the plugin. The
UI asks the user first; without that confirmation the command fails with
CONFIRMATION_REQUIREDand the plugin never hears about it. confirmed: trueis set only by the host, and only after the user confirmed this run. It is not a field a panel, a keybind preset or a plugin can supply: the host computes it.- The AI agent can never confirm. A dangerous command the agent starts is always refused
before it reaches the plugin, even if the command is
ai_callable. The agent can propose it; only a person can run it. - A plugin SHOULD still refuse a dangerous command that arrives with
confirmed: false, withPLUGIN_ERRORand a message such as "this command needs confirmation". The host never sends one, so this check costs nothing and protects you from a host bug.
A confirmed dangerous run looks like this:
{
"jsonrpc": "2.0",
"id": 7,
"method": "rim.command.execute",
"params": {
"commandId": "restart_pod",
"args": { "namespace": "prod", "pod": "api-7f9c" },
"invoker": { "principalId": "rimp0_PWcag22GowE59KogGTaUReeoF_q4E5EVi28q83uaGBU", "displayName": "Jakub" },
"initiator": "user",
"confirmed": true
}
}
Changing what the gate means (for example letting a confirmation cover several runs) is a v2 change (section 16).
11. People: principals #
What a plugin sees of a person #
A plugin sees a person only as a Principal:
{ "principalId": "rimp0_PWcag22GowE59KogGTaUReeoF_q4E5EVi28q83uaGBU", "displayName": "Jakub" }
| Field | Rules |
|---|---|
principalId |
Opaque and stable. 1 to 128 characters from A-Z a-z 0-9 _ . -. Never the user's account UUID and never a name@domain. It does not change when the user's keys change. |
displayName |
A label to show. Not unique, may change at any time, never empty. Never use it as a key. |
A plugin may store and compare principalId. It must not parse it, derive anything from
its format, or assume its length or prefix. No other information about the user (account id,
email, server address) reaches a plugin through this protocol.
Principals appear in three places: principal in the handshake, invoker in every
rim.command.execute, and principal in rim.principal.changed.
The signed-in-user notice: rim.principal.changed #
The host sends this notification to every running plugin whenever the signed-in user changes: sign-in, sign-out, switching account, or a new principal id scheme (below).
{ "jsonrpc": "2.0", "method": "rim.principal.changed", "params": { "principal": null } }
params.principal is the signed-in user from now on, or null after sign-out. It replaces the
principal the plugin got in the handshake. A plugin that keeps per-user state SHOULD switch to
the new user's state, and after sign-out SHOULD stop doing anything on the old user's behalf. If
the user changes while the handshake is in flight, the host sends rim.principal.changed right
after the handshake completes, so a plugin never keeps a stale principal.
A plugin that does not care about the user simply ignores this notification, as it would any notification it does not use.
Today's ids are provisional, and will change once #
Until RIM ships key-based identity, every principalId is provisional: it starts with
rimp0_ and is a one-way hash of the user's account id. It is the same for one account on every
device and in every session, and it cannot be turned back into the account id.
When key identity ships:
- each person's id becomes a hash of the first entry of their key log (
rimp1_, seedocs/specs/key-identity-v1.md), which survives key rotation; - the switch happens once. Every
rimp0_id maps to exactly one new id, and new ids never start withrimp0_; - the host tells running plugins with
rim.principal.changed, carrying the new id of the signed-in user; - the host also sends the full old-to-new mapping in
rim.principals.remapped(below), so a plugin that storedrimp0_ids can rewrite them.
So: keep stored principal ids somewhere you can rewrite (a table column or a map key, not a filename baked into a git repo), and treat "the same person under a new id" as possible exactly once.
The id switch notice: rim.principals.remapped #
Added within v1 (section 16) with the desktop client of key identity. Old hosts never send it; old plugins ignore it like any notification they do not use.
{ "jsonrpc": "2.0", "method": "rim.principals.remapped",
"params": { "mappings": [ { "from": "rimp0_PWcag22GowE59KogGTaUReeoF_q4E5EVi28q83uaGBU",
"to": "rimp1_nz1POml8JmCMcFDECrtu60ibohPKnywhcExmOPMvTUo" } ] } }
| Param | Type | Meaning |
|---|---|---|
mappings |
array of { from, to }, 1 to 1000 items |
Every provisional id this host has seen switch, with its new id. Each from appears once. |
- The host sends it right after every successful handshake whenever it knows at least one
mapping, and to every running plugin when it learns a new one (in that case after the
rim.principal.changedthat carries the new id of the signed-in user). - The list is the whole mapping this host knows, not only the new entries, so the same
mapping arrives many times. Applying it must be idempotent: replace every stored
fromwith itsto, and leave ids that are not afromalone. - A host learns a mapping when it sees a person's key-based id for the first time; today that is the signed-in user, since no other person reaches a plugin yet. Mappings are kept per device, so another device may send a different (shorter or longer) list.
fromalways starts withrimp0_andtonever does; still treat both as opaque ids.
12. Capabilities #
A capability is a permission the user grants a plugin. The plugin lists the capabilities it wants
in its manifest (capabilities); the user approves some or all of them; the approved list
arrives in grantedCapabilities in the handshake.
The host enforces capabilities in two places:
- On commands. A command whose manifest entry lists
required_capabilitiesis refused before it reaches the plugin unless every one of them is approved. - On plugin-to-host calls. Each host method that needs a capability is checked against the
approved list. A call without it is answered with
CAPABILITY_DENIED(-32000); a notification without it is dropped. Each capability also has a rate limit, 120 calls per minute by default, counted per plugin; a call over the limit getsRATE_LIMITED(-32001).rim.logneeds no capability and has no limit.
Capability names #
The protocol knows two kinds of capability name. Both are valid in grantedCapabilities in v1:
- Plugin-scoped names,
plugin.<pluginId>.<verb>, such asplugin.dev.example.k8stools.restart_pod.required_capabilitieson a command can point at them. The host guards no method of its own with them; it checks them only on commands. - Built-in names, which guard things the host does for the plugin:
| Capability | Guards |
|---|---|
chat.read |
rim.chat.list_messages, rim.chat.get_message, rim.chat.search_messages, in plaintext conversations |
chat.write |
rim.chat.post_message, in plaintext conversations |
chat.read.encrypted |
The chat.read methods in end-to-end encrypted conversations too; declared only with chat.read |
chat.write.encrypted |
rim.chat.post_message in end-to-end encrypted conversations too; declared only with chat.write |
events.read |
rim.events.subscribe |
ui.panel |
rim.ui.update_panel (and the unimplemented rim.ui.show_panel) |
ui.notify |
Notifications a plugin's panel sends through the panel bridge; rim.ui.show_notification is unimplemented on the process side |
local.process |
rim.process.spawn, rim.process.kill (unimplemented) |
network |
rim.network.http_request, rim.network.websocket_connect (unimplemented) |
fs.read, fs.write |
rim.fs.read_file, rim.fs.list_dir, rim.fs.write_file (unimplemented) |
None of the methods the built-in names guard is part of frozen v1 (section 17).
What the host enforces #
These are properties of the rim-ui host, not of the protocol, and a host may change them without
a protocol change. They changed with the TypeScript SDK PR (rim-ui #16), so both are described;
check rim-ui/src-tauri/src/plugin_host/validation.rs in the host version you target.
- Which names a manifest may declare. A manifest that declares any other name fails to
install.
- From rim-ui #44 onward: the built-in names
ui.panel,ui.notify,chat.read,chat.write,chat.read.encryptedandchat.write.encrypted, and the plugin's own keysplugin.<pluginId>.<verb>, where the verb uses only[a-z0-9_.]and neither starts nor ends with.nor contains... An encrypted chat name declared without its plain one is rejected. The other built-in names are rejected:events.readguards a call that nothing serves and that is not in v1, andlocal.process,network,fs.readandfs.writeguard nothing at all. AWebSandboxedplugin may also declarenetwork.fetch(web-plugins-v1.md). - From rim-ui #16 to #44: the same rules with only
ui.panelandui.notifyof the built-in names;chat.readandchat.writeare rejected too. - Before rim-ui #16: only
plugin.<pluginId>.<verb>keys, with the whole key in[a-z0-9_.]. No plugin could hold a built-in name, sorim.ui.update_panelwas always answeredCAPABILITY_DENIED, and a plugin id with an uppercase letter or a hyphen could declare no capability at all. - In both: a key is 4 to 128 bytes long and has no segment named
system,internal,adminoraudit.
- From rim-ui #44 onward: the built-in names
- Approving plugin-scoped names. The approval prompt lists only built-in names, so a
plugin.<pluginId>.<verb>key a manifest declares is never approved, and a command whoserequired_capabilitiesnames one is always refused. - Approving while the plugin runs.
grantedCapabilitiesis the approved list when the connection started, and v1 has no notification for changes to it. A capability the user approves while the plugin runs reaches the command gate at once, but the plugin's own calls to the host are checked against the start-time list until the process restarts. A plugin that needs a newly approved capability should ask the user to disable and re-enable it. - Revoking. Revoking a capability marks the plugin disabled, so it does not start with the
app next time.
- From rim-ui #16 onward: revoking also stops the running plugin, so a revoked capability is never used again.
- Before rim-ui #16: the running process was not stopped, and its own calls kept the start-time list until it stopped.
Rules for v1 #
grantedCapabilitiesis a list of strings. A plugin MUST ignore names it does not know.- A new capability name may be added within v1 (section 16). A plugin learns about it only by
finding it in
grantedCapabilities. - A capability is a host-side permission, not a sandbox. A local plugin process runs as the user and can do anything the user can; capabilities govern what the host will do for it.
13. Heartbeats #
Every 30 seconds the host sends:
{ "jsonrpc": "2.0", "id": -1, "method": "rim.heartbeat" }
There are no params. The plugin answers with any object as result; {} will do, and the
reference plugin sends {"pong": true}:
{ "jsonrpc": "2.0", "id": -1, "result": { "pong": true } }
A plugin that has not answered within 5 seconds is marked unresponsive. A late answer to that heartbeat is dropped; the mark clears when the plugin answers a later one. A plugin that does long work SHOULD do it off the thread that reads stdin, so heartbeats keep flowing.
14. Logging #
rim.log (plugin to host) writes one line to the host log, tagged with the plugin id. It needs no
capability and is the one call allowed before the handshake is answered.
| Param | Type | Meaning |
|---|---|---|
level |
"debug", "info", "warn" or "error" |
Severity. Any other value is INVALID_PARAMS. |
message |
string | The line to log. |
Send it as a notification (no id), which gets no answer:
{ "jsonrpc": "2.0", "method": "rim.log", "params": { "level": "info", "message": "handshake complete" } }
or as a request (with an id), which the host answers with { "ok": true }:
{ "jsonrpc": "2.0", "id": "log-1", "result": { "ok": true } }
stderr also works for debugging, but only rim.log lines carry the plugin id and a level.
15. Shutdown and app quit #
rim.shutdown is a notification from the host: exit now.
{ "jsonrpc": "2.0", "method": "rim.shutdown" }
What the host does, in order:
- sends
rim.shutdown(it waits at most 250 ms to write it, so a plugin that stopped reading cannot hold up the stop); - closes the plugin's stdin;
- waits 1 second for the process to exit;
- on Unix, sends
SIGTERMand waits 2 more seconds; on Windows, it kills the process at this point instead; - kills the process (
SIGKILL) if it is still running.
When the whole app quits, every plugin gets the same sequence at the same moment, and any plugin still running 4 seconds after the quit began is killed outright.
What a plugin should do:
- On
rim.shutdownor on end-of-file on stdin, flush what must be flushed, and exit. Aim for well under a second; anything after the first second is borrowed time. - Do not send anything after
rim.shutdown; nobody is reading. - Do not rely on
SIGTERMhandlers for cleanup: Windows never sends one, and a plugin killed at the deadline gets no signal it can handle.
16. Compatibility and versioning #
protocolVersion is a single integer. There are no minor versions: v1 is everything in the
schema plus any methods added under the rules below.
What may still be added within v1 #
- A new method or notification in either direction. Old receivers answer an unknown request with
METHOD_NOT_FOUNDand ignore an unknown notification, so old plugins and old hosts keep working. - Freezing a method that is listed as not frozen (section 17), as a new schema entry. Until then its shape may change without notice.
- A new capability name in
grantedCapabilities. - A new error code that is not one of the codes in section 8, documented in the schema before the host sends it.
What forces v2 #
- Adding, removing, renaming or retyping a field in any frozen frame, including an optional field.
- A new value in a closed set, such as
initiatoror therim.loglevels. - A different meaning for an existing method, field, value or error code, including the handshake, which keeps the same shape in every version.
- A change to framing, the frame size limit, the id rules or the confirmation gate.
How the two sides stay compatible #
- Senders emit exactly the frozen shapes; receivers ignore unknown fields rather than fail.
- The host lists every version it speaks in
supportedVersions, newest first, and the plugin picks one (section 7). When a v2 exists, a host will offer both[2, 1]for as long as it supports v1; the RIM roadmap commits to supporting the current and the previous protocol version. A v1 plugin keeps working on that host by answering1. - A plugin that only speaks a newer version than the host offers refuses the handshake, as in section 7, and the user sees why.
17. Method reference: what exists and what does not #
This is the complete list. If a method is not in this section, the host does not implement it.
Frozen in v1 #
| Method | Direction | Kind | Section |
|---|---|---|---|
rim.handshake |
host → plugin | request | 7 |
rim.heartbeat |
host → plugin | request | 13 |
rim.command.execute |
host → plugin | request | 9, 10 |
rim.principal.changed |
host → plugin | notification | 11 |
rim.principals.remapped |
host → plugin | notification | 11 |
rim.shutdown |
host → plugin | notification | 15 |
rim.log |
plugin → host | request or notification | 14 |
Routed by the host but not part of v1 #
The host's broker recognises these names, but their shapes are not frozen. The chat methods
may still change before they are frozen; the others will change (to camelCase, with a
Principal for every person) when they are wired end to end, so do not build on them.
| Method | Direction | Capability | What happens today |
|---|---|---|---|
rim.chat.list_messages, rim.chat.get_message, rim.chat.search_messages |
plugin → host | chat.read, and chat.read.encrypted in an encrypted conversation |
Served by the client message service. |
rim.chat.post_message |
plugin → host | chat.write, and chat.write.encrypted in an encrypted conversation |
Served by the client message service. |
rim.events.subscribe |
plugin → host | events.read |
Answered CAPABILITY_DENIED, since no host lets a manifest request events.read (section 12). Behind the gate it is not wired either. |
rim.event.<type> |
host → plugin | events.read |
Never sent today, since nothing can subscribe. |
rim.ui.update_panel |
plugin → host | ui.panel |
Wired to the panel state store. Hosts before rim-ui #16 cannot grant ui.panel (section 12) and answer CAPABILITY_DENIED. The panel page receives the stored state as panel.state_update (docs/local_plugin_host.md). |
The chat calls are deliberately not in v1: they go through the client's single message
service, so that a plugin sees exactly what the user sees, and their shapes are not frozen yet.
e2ee-mls-v1.md, "Plugins and the client message service", defines what they return and what
the encrypted capabilities add.
Not implemented #
Earlier RIM documents listed methods that the host has never implemented. They do not exist:
initialize,execute_command,deliver_event,shutdown(the real names arerim.handshake,rim.command.execute,rim.event.<type>andrim.shutdown);rim.chat.get_conversations,rim.chat.edit_message,rim.chat.delete_message;rim.ui.register_panel,rim.ui.show_panel,rim.ui.show_notification(panels are declared in the manifest; notifications are a panel-side feature, seedocs/local_plugin_host.md);rim.settings.get,rim.settings.set;rim.process.*,rim.network.*,rim.fs.*;- MessagePack framing and any
supported_formats/chosen_formatnegotiation.
Calling one gets METHOD_NOT_FOUND, except that a name in the host's capability table
(section 12) is checked first and gets CAPABILITY_DENIED when the plugin lacks that capability.
18. Worked example: one whole session #
A plugin dev.example.k8stools with two commands, list_pods and restart_pod (dangerous).
Arrows show direction; each line is one frame's JSON payload.
# 1. The host starts the plugin and handshakes. The plugin logs before answering, which is allowed.
host → plugin {"jsonrpc":"2.0","id":1,"method":"rim.handshake","params":{"protocolVersion":1,"supportedVersions":[1],"pluginId":"dev.example.k8stools","hostVersion":"0.1.0","grantedCapabilities":["plugin.dev.example.k8stools.restart_pod"],"principal":{"principalId":"rimp0_PWcag22GowE59KogGTaUReeoF_q4E5EVi28q83uaGBU","displayName":"Jakub"}}}
plugin → host {"jsonrpc":"2.0","method":"rim.log","params":{"level":"info","message":"k8stools starting"}}
plugin → host {"jsonrpc":"2.0","id":1,"result":{"accepted":true,"protocolVersion":1,"pluginId":"dev.example.k8stools"}}
# 2. The user runs "List pods" from the palette.
host → plugin {"jsonrpc":"2.0","id":2,"method":"rim.command.execute","params":{"commandId":"list_pods","args":{"namespace":"prod"},"invoker":{"principalId":"rimp0_PWcag22GowE59KogGTaUReeoF_q4E5EVi28q83uaGBU","displayName":"Jakub"},"initiator":"user","confirmed":false}}
plugin → host {"jsonrpc":"2.0","id":2,"result":{"payload":[{"name":"api-7f9c","status":"Running"}],"message":"1 pod in prod"}}
# 3. The plugin asks for something the host does not implement; the host says so.
plugin → host {"jsonrpc":"2.0","id":"p-1","method":"rim.kube.config"}
host → plugin {"jsonrpc":"2.0","id":"p-1","error":{"code":-32601,"message":"method not found: rim.kube.config"}}
# 4. The AI agent tries to restart a pod. The command is dangerous, so the host refuses it
# before it reaches the plugin: no frame is sent at all.
# 5. The user restarts the pod and confirms the dialog. The host sets confirmed: true.
host → plugin {"jsonrpc":"2.0","id":3,"method":"rim.command.execute","params":{"commandId":"restart_pod","args":{"namespace":"prod","pod":"api-7f9c"},"invoker":{"principalId":"rimp0_PWcag22GowE59KogGTaUReeoF_q4E5EVi28q83uaGBU","displayName":"Jakub"},"initiator":"user","confirmed":true}}
plugin → host {"jsonrpc":"2.0","id":3,"error":{"code":-32002,"message":"kubectl failed: context not found"}}
# 6. Thirty seconds after the connection started, a heartbeat.
host → plugin {"jsonrpc":"2.0","id":-1,"method":"rim.heartbeat"}
plugin → host {"jsonrpc":"2.0","id":-1,"result":{}}
# 7. The user signs out.
host → plugin {"jsonrpc":"2.0","method":"rim.principal.changed","params":{"principal":null}}
# 8. The user disables the plugin. The host sends rim.shutdown and closes stdin; the plugin exits.
host → plugin {"jsonrpc":"2.0","method":"rim.shutdown"}
Step 5 shows the command failing with PLUGIN_ERROR: the user sees "kubectl failed: context not
found". Note in step 3 that the plugin's string id "p-1" comes back unchanged.
19. A complete plugin in Python #
A conforming v1 plugin, standard library only, with one command, greet. Put two files in one
directory: the manifest rim-plugin.json,
{
"manifest_version": 1,
"plugin_id": "dev.example.greeter",
"display_name": "Greeter",
"version": "0.1.0",
"description": "Says hello.",
"runtime_kind": "DesktopLocalProcess",
"entrypoint": "main.py",
"commands": [
{
"id": "greet",
"title": "Greeter: Greet",
"description": "Greets someone by name.",
"arguments": [{ "name": "name", "type": "string", "required": false }]
}
]
}
and main.py, made executable (chmod +x main.py):
#!/usr/bin/env python3
import json, struct, sys
PLUGIN_ID = "dev.example.greeter" # must match plugin_id in rim-plugin.json
VERSION = 1
def read_exact(n):
buf = b""
while len(buf) < n:
chunk = sys.stdin.buffer.read(n - len(buf))
if not chunk:
raise EOFError
buf += chunk
return buf
def read_frame():
(length,) = struct.unpack("<I", read_exact(4))
return json.loads(read_exact(length))
def send(message):
body = json.dumps(message).encode("utf-8")
sys.stdout.buffer.write(struct.pack("<I", len(body)) + body)
sys.stdout.buffer.flush()
def answer(request, result=None, error=None):
reply = {"jsonrpc": "2.0", "id": request["id"]}
reply["error" if error else "result"] = error or result
send(reply)
def log(level, message):
send({"jsonrpc": "2.0", "method": "rim.log", "params": {"level": level, "message": message}})
def handle(msg):
method, params = msg.get("method"), msg.get("params") or {}
if method == "rim.handshake":
ok = VERSION in params.get("supportedVersions", []) and params.get("pluginId") == PLUGIN_ID
answer(msg, {"accepted": ok, "protocolVersion": VERSION, "pluginId": PLUGIN_ID,
**({} if ok else {"error": "need protocol 1 and plugin id " + PLUGIN_ID})})
if not ok:
sys.exit(0) # refused: the host stops us anyway
elif method == "rim.heartbeat":
answer(msg, {})
elif method == "rim.command.execute":
if params.get("commandId") == "greet":
name = (params.get("args") or {}).get("name") or "world"
answer(msg, {"payload": f"Hello, {name}!"})
else:
answer(msg, error={"code": -32602, "message": f"unknown command: {params.get('commandId')}"})
elif method == "rim.shutdown":
sys.exit(0)
elif msg.get("id") is not None and method is not None:
answer(msg, error={"code": -32601, "message": f"method not found: {method}"})
# Anything else (unknown notifications, responses to our own requests) is ignored.
def main():
log("info", "greeter started")
while True:
try:
msg = read_frame()
except EOFError:
return # the host closed stdin: exit
except ValueError:
continue # not JSON: drop it, framing is still intact
if isinstance(msg, dict): # anything else is not JSON-RPC: drop it too
handle(msg)
if __name__ == "__main__":
main()
The in-tree reference plugin, rim-ui/plugins/hello-world/, does the same with more comments and
is driven through every v1 message by the host's conformance tests.
20. Plugin author checklist #
- The entrypoint is executable and writes nothing but frames to stdout.
- Frames use a 4-byte little-endian length and stay under 4 MiB.
- Partial reads are looped; end-of-file on stdin means exit.
- Nothing but
rim.logis sent before the handshake answer. - The handshake picks a version from
supportedVersions, echoespluginId, and refuses withaccepted: falseand anerrorotherwise. - Every request gets exactly one answer with the same
id, including heartbeats. - Unknown requests get
METHOD_NOT_FOUND; unknown notifications and unknown fields are ignored. - An unknown
commandIdor badargsgetsINVALID_PARAMS; a failed command getsPLUGIN_ERRORwith a message a person can read. - A dangerous command with
confirmed: falseis refused. -
principalIdis stored only where it can be rewritten, and never parsed. -
rim.shutdownmakes the plugin exit within a second. - Commands answer within 30 seconds; heartbeats keep flowing during long work.
Where this lives in the host #
| Concern | File in rim-ui/src-tauri/src/plugin_host/ |
|---|---|
| Frozen frame types, principal derivation | protocol.rs |
| Conformance tests against the schema | protocol/conformance.rs |
| Framing, error codes, size limit | wire.rs |
| Handshake validation | handshake.rs |
| Broker loop, plugin-to-host dispatch, heartbeats, rate limits | broker.rs |
| Capability table | capability.rs |
| Command gates and dispatch | command_exec.rs |
| Start, stop, app quit, principal notices | runtime.rs, process.rs |
| Manifest and install-time validation | manifest.rs, validation.rs |