npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rim-platform/plugin-sdk

v0.2.0

Published

TypeScript SDK for RIM plugin protocol v1

Readme

@rim-platform/plugin-sdk

Write a RIM plugin in TypeScript. A plugin is either a local process the RIM desktop app starts and talks to over stdio (DesktopLocalProcess), or a bundle that runs in a sandboxed frame of the app, on the desktop and on the web, and talks over a MessagePort (WebSandboxed). The SDK speaks plugin protocol v1 for you over either transport: the handshake and version negotiation, heartbeats, the dangerous-command gate, errors and shutdown.

The SDK is developed in rim-ui and released to npm on its own, with the tag plugin-sdk-v<version>; it keeps a version of its own, apart from the app's. Start from plugins/template-ts, which uses it.

Install

npm install --save-dev @rim-platform/plugin-sdk esbuild

The package ships JavaScript and type declarations, the in-memory test host (@rim-platform/plugin-sdk/testing) and the bundler (npx rim-plugin-bundle). esbuild is an optional peer dependency that only the bundler needs.

Requirements

  • Node 22.18 or newer. It runs .ts files as they are, so a plugin needs no build step while you work on it: node main.ts runs it with the SDK from node_modules.
  • esbuild, only to bundle a plugin for installing (see below).

Two transports, one API

run() picks the transport itself:

  • in a WebSandboxed plugin's frame, where the frame shell has set globalThis.rimPluginPort, it talks over that port (one JSON text per message) and checks the plugin against the manifest text in globalThis.rimPluginManifest;
  • anywhere else it talks over the process's stdio, in length-prefixed frames, and reads rim-plugin.json from the working directory.

RunOptions.transport overrides both. A transport is { messages: AsyncIterable<string>; send(text): void; close(): void }; stdioTransport() and portTransport(port) are the two the SDK ships.

The package has a browser export condition, dist/index.browser.js, with the same API and no node: imports; rim-plugin-bundle uses it for WebSandboxed plugins. A web plugin gets no Node APIs and no filesystem: it reaches the network only at the origins its manifest lists in network_origins, once the user approves network.fetch.

A plugin in one file

#!/usr/bin/env node
import { definePlugin, PluginError } from "@rim-platform/plugin-sdk";

await definePlugin({
  id: "dev.example.pods", // plugin_id in rim-plugin.json
  commands: {
    list_pods: {
      async run({ args, invoker, host }) {
        host.log.info(`listing pods for ${invoker?.displayName ?? "nobody"}`);
        return { payload: await listPods(args.namespace), message: "3 pods" };
      },
    },
    restart_pod: {
      dangerous: true, // must match "dangerous": true in rim-plugin.json
      async run({ args }) {
        if (typeof args.pod !== "string") throw new PluginError("pod is required");
        await restart(args.pod);
      },
    },
  },
}).run();

The entrypoint in rim-plugin.json points at this file, and the file must be executable (chmod +x), because the host runs it directly through its #! line.

What the SDK guarantees

  • Commands. Each key in commands is a command id from rim-plugin.json. run gets a frozen context: args, invoker, initiator ("user" or "agent"), confirmed and host. Return { payload, message } or nothing. Unknown command ids are answered with INVALID_PARAMS.
  • Dangerous commands. Mark a command dangerous: true here and in the manifest. The host asks the user to confirm every run and never lets the AI agent run one. The SDK also refuses, without calling run, any run that is not confirmed by the user (PLUGIN_ERROR with data: { reason: "confirmation_required" }). Nothing in the SDK lets a plugin set confirmed itself. If the manifest marks a command dangerous and the code does not, the SDK treats it as dangerous.
  • People. The signed-in user and a command's invoker are { principalId, displayName } and nothing else. principalId is an opaque PrincipalId: store it and compare it, never parse it. Ids starting rimp0_ are provisional and are replaced once, when the person enrolls a key identity, so keep stored ids somewhere you can rewrite. onPrincipalChanged runs when the user signs in, out or switches, or their id switches; onPrincipalsRemapped gets every { from, to } switch the host knows, after every handshake, so apply it idempotently.
  • Errors. Throw PluginError(message, { code, data }) to choose the JSON-RPC error; anything else thrown becomes PLUGIN_ERROR with its message. ErrorCode lists the protocol's codes.
  • Compatibility. The SDK ignores fields and notifications it does not know, answers unknown requests with METHOD_NOT_FOUND, and answers a host that speaks no version it knows with accepted: false, naming the version it speaks.
  • Manifest check. On start the SDK reads rim-plugin.json from the working directory (the plugin directory, when the host starts it). It refuses the handshake when the manifest names another plugin, and logs commands that are in one place but not the other.
  • Shutdown. On rim.shutdown or when stdin closes, the SDK runs onShutdown and exits. The host kills a plugin that is still running about a second later.

Logging

stdout carries protocol frames, so the SDK sends console.log and friends to stderr, which the host passes through. host.log.info(...) (and debug, warn, error) writes to the RIM log with the plugin id in front. Never write to process.stdout yourself.

The host

onReady(host) runs after the handshake, and every command context has host:

| Field | Meaning | | --------------------- | ------------------------------------------------------------ | | pluginId | The id the host started the plugin under. | | hostVersion | The RIM app's version. | | protocolVersion | The version this connection speaks (1). | | grantedCapabilities | What the user approved when the plugin started. | | principal | The signed-in user right now, or null. | | log | Lines for the RIM log. | | panels | Panel state, below. | | storage | A WebSandboxed plugin's small store, below. | | settings | What the user saved in the plugin's settings form, below. | | chat | The person's messages, below. |

Context

A command that acts on where the user is says so with required_context in rim-plugin.json, and the host adds the ids to context.args as strings:

| required_context | The command runs | The host adds | | ------------------ | ----------------------------------- | ------------------------------------------------------------------- | | "conversation" | while a conversation is open | conversation_id, and thread_id while a thread is open beside it | | "message" | on a message, from its context menu | message_id, conversation_id, and thread_id for a thread reply | | "workspace" | in a workspace | workspace_id |

{ "id": "ask", "title": "Polls: Ask", "description": "Posts a poll.", "required_context": ["conversation"] }
run: async ({ args, host }) => {
  const conversationId = args.conversation_id as string;
  const threadId = args.thread_id as string | undefined;
  await host.chat.postMessage(conversationId, "Lunch?", threadId ? { threadId } : {});
},

Without the context the host never calls the plugin: the palette leaves the command out, and a keybind, a panel or the AI agent is told "Open a conversation first" (or "Select a message first"). The added ids replace any argument of the same name, and a manifest whose command requires a context and also declares an argument named like one of its ids fails to install. A command without required_context gets no ids, as before; template-ts and hello-world declare none.

Panels (experimental)

host.panels.set(panelId, state) replaces a panel's state and host.panels.patch(panelId, partial) merges top-level keys into it. Both send rim.ui.update_panel, which the host routes but protocol v1 has not frozen yet, so the shape may still change. The plugin needs "ui.panel" in its manifest capabilities, approved by the user; otherwise the call rejects with a HostRequestError whose code is CAPABILITY_DENIED. A panel id the manifest does not declare is rejected before anything is sent.

Storage (experimental, WebSandboxed)

A web plugin's frame has an opaque origin, so no localStorage or IndexedDB of its own. await host.storage.get(key) answers the stored JSON value or null; await host.storage.set(key, value) stores it, and null deletes the key. The host keeps 1 MiB per plugin, signed-in account and device, unencrypted: never keep tokens in it. Keys are 1 to 128 characters of A-Z a-z 0-9 . _ -; a set past the quota rejects with INVALID_PARAMS and data: { reason: "quota" }, and with nobody signed in both reject with INVALID_PARAMS. rim.storage.* is not frozen in v1. A local-process host answers both with METHOD_NOT_FOUND: a process has its own files.

Settings

The settings a plugin declares in settings_schema in rim-plugin.json get a form in the plugin manager; only the user changes them. await host.settings.get() answers the whole set, frozen: one member per setting that has a saved value or a default, named by its key, a secret setting's value in plain form. With nobody signed in it holds the defaults only. onSettingsChanged(values, host) in definePlugin gets the whole set again after every save and after every principal change; replace what the plugin held, since a setting missing from values was cleared. Of all the sets the plugin received, from either, the last is the current one.

let namespace = "default";
const apply = (values: Readonly<Record<string, unknown>>) => {
  namespace = typeof values.namespace === "string" ? values.namespace : "default";
};

definePlugin({
  id: "dev.example.pods",
  async onReady(host) {
    try {
      apply(await host.settings.get());
    } catch (error) {
      // A host from before settings: keep the defaults.
      if (!(error instanceof HostRequestError && error.code === ErrorCode.METHOD_NOT_FOUND)) {
        throw error;
      }
    }
  },
  onSettingsChanged: (values) => apply(values),
});

A value saved under an older version of the plugin may not fit the current schema, so check each value before using it and ignore keys you do not know. host.settings.get() rejects with a HostRequestError whose code is METHOD_NOT_FOUND on a host from before settings, which also never calls onSettingsChanged, and with INTERNAL_ERROR and data: { reason: "secretsLocked" } while the host cannot read a saved secret, for example while the desktop's keyring is locked; the host sends no change then either. Reading its own settings needs no capability. The web app keeps no secret settings, so a web plugin running there never gets a value for one. rim.settings.get and rim.settings.changed are frozen in protocol v1.

Chat (experimental)

host.chat reads and posts the person's messages through the app's message service, the same for both kinds of plugin (the method reference):

const { messages, hasOlder } = await host.chat.listMessages(conversationId, { limit: 50 });
const message = await host.chat.getMessage(messageId); // or null
const hits = await host.chat.searchMessages("deploy", { conversationId });
const { id } = await host.chat.postMessage(conversationId, "on it", { threadId });

A message is { id, conversationId, threadId, author, text, sentAt, editedAt, encrypted, attachments }, with author a Principal and attachments as name, type and size only. Reads come from what the person's device stores. The calls need "chat.read" or "chat.write" approved, and in an encrypted DM or group also "chat.read.encrypted" or "chat.write.encrypted" (a manifest asking for an encrypted one must ask for the plain one too); otherwise they reject with CAPABILITY_DENIED. rim.chat.* is routed by both hosts but not frozen in v1, so the shapes may still change.

Testing without the app

@rim-platform/plugin-sdk/testing runs a plugin against an in-memory host. Like the app, it refuses host.panels calls with CAPABILITY_DENIED unless grantedCapabilities includes "ui.panel" (and host.chat calls without "chat.read" or "chat.write"), and onRequest answers the rest:

import { startTestHost } from "@rim-platform/plugin-sdk/testing";

const host = startTestHost(createPlugin(), { manifestPath: "rim-plugin.json" });
await host.handshake();
expect(await host.execute("greet")).toEqual({ result: { payload: "Hello, Test User!" } });
expect((await host.execute("restart_pod", { pod: "web-1" })).error?.code).toBe(-32002);
await host.execute("restart_pod", { pod: "web-1" }, { confirmed: true });
await host.shutdown();

startTestHost(plugin, { transport: "port" }) runs the plugin over a Node MessageChannel, as a web plugin's frame does, with the same assertions. With settings: { ... } the test host answers rim.settings.get with those values, and host.changeSettings(values) sends them in rim.settings.changed, as a save does, and answers with them from then on; without settings a read gets METHOD_NOT_FOUND, as from a host from before settings, unless onRequest answers it. onRequest can answer rim.storage.get and rim.storage.set to test a plugin's store, and the rim.chat.* calls, which the test host refuses with CAPABILITY_DENIED unless grantedCapabilities has "chat.read" (or "chat.write" for posting).

validateManifest checks a manifest by the rules the hosts install it by, and throws a ManifestError with the host's reason; parseManifest and checkInstall are its two halves. The web app installs by these same rules:

import { readFileSync } from "node:fs";
import { validateManifest } from "@rim-platform/plugin-sdk/testing";

const manifest = JSON.parse(readFileSync("rim-plugin.json", "utf8"));
expect(() => validateManifest({ ...manifest, entrypoint: "main.mjs" })).not.toThrow();

Installing a plugin

A plugin in ~/.rim/plugins runs from its own folder, without the SDK source or your node_modules, so bundle it into one file first:

npx rim-plugin-bundle .                               # in the plugin's folder
npm run plugin:bundle -- plugins/template-ts          # or, in rim-ui
cp -r plugins/template-ts/dist/dev.example.template-ts ~/.rim/plugins/

A folder that already holds its build, a manifest whose entrypoint is main.mjs with that main.mjs beside it, is bundled as it is: the bundler copies main.mjs without building it again and prints using the built main.mjs as it is. --entry <file> builds from that source instead.

The output folder is emptied first, so the bundler refuses a folder that holds the plugin or has other files in it. The bundle directory holds rim-plugin.json (with entrypoint set to main.mjs), main.mjs with the SDK inside, and the plugin's panels/ folder (dotfiles left out). Beside the folder the bundler writes the same files as one <plugin_id>-<version>.rimbundle and prints its hash and size:

sha256 <64 lowercase hex characters>  size <bytes>

sha256sum on the file prints the same hash. Install the folder on the desktop, or serve the .rimbundle over https with Access-Control-Allow-Origin: * and install it on the web or the desktop with Install from link and https://host/path/<plugin_id>-<version>.rimbundle#sha256=<hash>.

For a WebSandboxed plugin the bundler builds for the browser (platform: "browser", the browser condition, ES2022, no #! line) and fails when the output still imports something or names a node: module: the frame shell loads the entry from a blob: URL, where nothing can be imported. Every file besides the manifest and main.mjs comes from panels/. Then enable the plugin in Settings → Plugins.

Publishing a plugin

A plugin lives in a repository of its own, plugin-<id>, that holds the built plugin at its root beside the sources, so Install from git URL finds a complete plugin:

plugin-<id>/
  rim-plugin.json   # entrypoint "main.mjs"
  main.mjs          # built and committed
  panels/           # the panels, as the bundle ships them
  src/main.ts       # the source the build starts from
  package.json      # devDependencies: @rim-platform/plugin-sdk, esbuild

--in-place builds the source --entry names into main.mjs beside the manifest, which must already point at main.mjs. --check builds it again into a temporary folder and exits 1, naming each file, when the committed main.mjs or panels/ differ from that build, so a repository's CI catches a main.mjs nobody rebuilt:

npx rim-plugin-bundle --in-place --entry src/main.ts .   # after changing the source
npx rim-plugin-bundle --check --entry src/main.ts .      # in CI

plugin-k8s-pods (a local process) and plugin-github-prs (a web plugin) are two such repositories, with CI that runs --check and a release workflow that attaches the .rimbundle.

The build names no absolute path and no time, so the same sources and the same SDK version give the same bytes on every machine; commit the lockfile so CI builds with the SDK the committed main.mjs was built with. A release is a tag; its .rimbundle can go on the release for Install from link. npx rim-plugin-bundle . packages the committed main.mjs as it is; once --check passes, that is byte for byte the bundle npx rim-plugin-bundle --entry src/main.ts . builds from the sources.

For a WebSandboxed plugin the desktop takes rim-plugin.json, the entrypoint and panels/ from the checkout and ignores the rest: the sources, package.json and the README stay in the checkout and never reach the plugin's frame. Those three parts follow the bundle rules (names starting with . are skipped, and any other name under panels/ must be a bundle path), and signing an installed web plugin gives the same bundle hash as its .rimbundle. A local-process plugin's package.json asks for npm install only when it lists dependencies, optionalDependencies or peerDependencies; a committed, self-contained main.mjs with only devDependencies installs without that step.

Working on the SDK

  • src/generated/protocol.ts is generated from the schema: run npm run sdk:generate after the schema changes. A test fails while it is stale.
  • npm run sdk:build builds the package into dist/: the JavaScript, the type declarations and the bundler. The plugins in rim-ui import @rim-platform/plugin-sdk as any plugin does; the npm workspace links it to this folder, so they get dist/, exactly what the package ships. npm test builds it first; after changing the SDK, rebuild before running a plugin by hand.
  • npm run sdk:typecheck builds it and checks the SDK and the plugins with the strict settings plugins use (erasableSyntaxOnly, so Node can run the source).
  • npm run sdk:smoke packs the SDK as npm would publish it, installs the tarball into a copy of the template outside the repository, and type-checks, tests, runs and bundles the copy there. docs/release-sdk.md says how a release publishes the package.
  • npm test runs the SDK tests, including the template over real stdio, both from source and bundled, and over a MessageChannel. The app's web host runs the template's browser build over a port in its own conformance test (src/features/plugins/web/conformance.test.ts). The Rust conformance tests (src-tauri/src/plugin_host/protocol/conformance.rs) run the template against the schema and through the host's own start-up and command path.