Plugin chat methods (rim.chat.*)
The client message service lets a plugin read and post the person's messages. It is the
same for a web plugin and a local process: the web host calls it in the page, and the
Rust broker relays a local process's calls to it. Reads come from the device's local
messages only: the desktop's message cache, or in the web app the browser's encrypted store
once the browser is set up as a linked device (e2ee-web.md); posts go through the person's own send path and are encrypted where
the conversation is. The contract is the "Plugins and the client message service" section
of ../rim-server/docs/specs/e2ee-mls-v1.md.
These methods are routed, not frozen: both hosts serve them, the shapes below are
listed under x-notFrozen.definitions in protocol/plugin-protocol-v1.schema.json, and
they may still change before they are frozen into protocol v1.
Capabilities #
| Capability | Lets the plugin | Consent line |
|---|---|---|
chat.read |
Call the three read methods in plaintext conversations | "Read your conversations: this plugin sees the messages stored on this device, except those of encrypted DMs and groups." |
chat.write |
Call rim.chat.post_message in plaintext conversations |
"Post as you: this plugin sends messages in your name, except in encrypted DMs and groups." |
chat.read.encrypted |
Read encrypted DMs and groups too | "Read your encrypted DMs and groups: this plugin sees their decrypted text and can send it anywhere its other permissions allow." |
chat.write.encrypted |
Post in encrypted DMs and groups too | "Post as you in your encrypted DMs and groups: this plugin sends messages in your name there, encrypted like your own." |
- A manifest that asks for an encrypted capability without its plain one fails to install
(
validation.rs,manifestRules.ts, vectors inprotocol/manifest-vectors.json). In the permission prompt, checking an encrypted capability checks its plain one and unchecking the plain one unchecks the encrypted one. - The broker checks the plain capability for each method (
capability.rs); a call without it isCAPABILITY_DENIEDand never reaches the service. The service then refuses an encrypted conversation without the encrypted capability, also withCAPABILITY_DENIED(capability denied: chat.read.encrypted), even when the plugin holdschat.read. - A conversation is encrypted when the server says so (
Conversation.encryptedAt), when this device holds group state for it, or when any of its cached rows is encrypted. Its plaintext from before the upgrade is behind the encrypted capability as well. - Each capability counts against the usual limit of 120 calls a minute per capability
(
chat.readfor the three reads,chat.writefor posting). - The capabilities a plugin started with are the ones checked; revoking any capability stops the plugin.
Methods #
Every call works in the workspace open in the app. Ids of messages, conversations and threads are the server's, and opaque to plugins.
A command learns the conversation the user is in by declaring required_context: ["conversation"] in rim-plugin.json: it then runs only while a conversation is open, and
its args carry conversation_id (a string) and, while a thread is open beside it,
thread_id (a string, left out otherwise), ready for conversationId and threadId below.
["message"] adds message_id and conversation_id (and thread_id for a thread reply)
for a command run on a message. The plugin SDK's README has the table.
rim.chat.list_messages #
Params { conversationId, before?, limit? }: the newest limit (1 to 100, default 50)
messages of the conversation, thread replies included, older than the message before
when given. Answers { messages, hasOlder }, oldest first.
rim.chat.get_message #
Params { messageId }. Answers { message }, or { message: null } when this device does
not have it or a plugin may not see it (below).
rim.chat.search_messages #
Params { query, conversationId?, limit? }: search of the messages on this device, newest
first; the server is not asked. On the desktop it is the local full-text index (every word
matches as a prefix); in the web app a case-insensitive substring match over the text of the
browser's stored messages. Answers { messages }. Without conversationId and without chat.read.encrypted, hits
in encrypted conversations are left out; naming an encrypted conversation without it is
refused.
rim.chat.post_message #
Params { conversationId, body, threadId? }: posts body (1 to 10,000 characters) as the
person, as a reply in the thread threadId when given. In an encrypted conversation the
message goes through the encrypted send path, on the desktop or in a browser set up as a
linked device. Mentions are written as <@principalId> tokens. Answers { id }, the new
message's id.
The message #
{
"id": "…", "conversationId": "…", "threadId": null,
"author": { "principalId": "rimp1_…", "displayName": "Ada" },
"text": "plan attached <@rimp1_…>",
"sentAt": "2026-10-01T09:05:00.000002Z", "editedAt": null,
"encrypted": true,
"attachments": [{ "name": "plan.pdf", "mime": "application/pdf", "size": 48213 }]
}
author is a protocol Principal: the key-based id when the person has one, else the
provisional rimp0_ id. <@user> mentions of plaintext messages are rewritten to
principal ids. encrypted says whether the message itself was end-to-end encrypted.
What a plugin never gets #
- Franking keys, franking receipts, attachment secrets or blob hashes, the decrypted content bytes, MLS group state, epochs, ciphertext, or keys of any kind.
- Attachment bytes: only name, type and size.
- Raw user ids.
- Rows this device could not decrypt (placeholders), messages that could not be verified, tombstones, and this device's sends the server has not acknowledged yet.
The projection is in Rust on the desktop (src-tauri/src/message_cache/plugin_view.rs)
and in TypeScript in the web app (src/features/e2ee/web/pluginView.ts); both are tested
against the shared vector protocol/plugin-chat-vectors.json.
Errors #
| Code | When |
|---|---|
CAPABILITY_DENIED (-32000) |
The plain capability is missing, or the conversation is encrypted and the encrypted one is |
RATE_LIMITED (-32001) |
More than 120 calls a minute on the capability |
INVALID_PARAMS (-32602) |
A param is missing or out of range, the conversation or thread is unknown, before names no message of the conversation, no workspace is open |
INTERNAL_ERROR (-32603) |
data.reason notCached: the conversation is not fully stored on this device yet (the app starts caching it; try again shortly). noCache: the app keeps no messages here (a browser not set up as a linked device). noEncryption: an encrypted conversation cannot be written from here (a browser not set up as a linked device). Otherwise the app failed |
Audit #
The host records, per plugin, when each capability was given and revoked and how many
encrypted messages the service handed it per day (encrypted: true messages in any
answer). Settings > Plugins > Activity shows both, with a Revoke button per capability.
On the desktop the record is in the Rust PluginStateStore (audit, kept through
updates, dropped on uninstall); on the web in the plugin's install record in IndexedDB.
Grant events are capped at 200 and daily counts at 90 days.
Where it lives #
| Concern | Where |
|---|---|
| Service, param checks, encrypted gate, audit counting | src/features/plugins/chat/messageService.ts |
| Wiring to the cache, the send paths and the stores | src/features/plugins/chat/appMessageService.ts |
| The cache's plugin projection and its commands | src-tauri/src/message_cache/plugin_view.rs, commands.rs (message_cache_plugin_*) |
| Local-process relay | src-tauri/src/plugin_host/chat_relay.rs, broker.rs (Routed::Chat), commands.rs (plugin_chat_respond); the page side src/features/plugins/chat/usePluginChatRelay.ts |
| Web host | src/features/plugins/web/pluginSession.ts (the chat hook), webPluginHost.ts |
| SDK | host.chat in sdk/plugin-sdk/src/plugin.ts |