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

openclaw-chat-bridge

v0.3.0

Published

OpenClaw plugin: shared settings, durable session media, and desktop control for companion chat apps.

Readme

openclaw-chat-bridge

An OpenClaw plugin that gives companion chat apps a place to keep the things a single device should not own.

Why

Two gaps show up as soon as you talk to an OpenClaw gateway from a phone:

Settings die with the device. Set a gateway avatar or a session label in the app, reinstall, and it is gone. There is nowhere gateway-side for a client to keep preferences that should follow the user.

Media never reaches the client. When an agent sends a video, it writes the file to the gateway host and hands it to whichever channel it was talking to — Discord, say. The session transcript keeps the filename as ordinary text. A chat client reading chat.history sees demo-01.mp4 as a string and has nothing to render, because the bytes were never part of the session. Verified against a live 2026.9 gateway: the transcript carried only text, toolCall, thinking and image blocks, artifacts.list held images and no video, and sessions.files.list reported zero media.

This plugin closes both gaps. Settings live on the gateway; media is archived per session and stays fetchable from any device, indefinitely.

Install

openclaw plugins install openclaw-chat-bridge
openclaw gateway restart

The plugin registers gateway RPC methods, an HTTP route and a delivery hook, and OpenClaw asks for explicit consent before granting those, so the install prompts before it proceeds. Answer the prompts and you are done.

Newer gateways (2026.9.2 confirmed) accept --accept-capabilities to skip the consent prompt non-interactively. Older ones do not have the option at all and reject the command outright — 2026.7.2-beta.7 answers does not recognize option "--accept-capabilities" — so leave it off unless you know the target has it.

Installing from a local checkout additionally needs --force, because a local path carries no ClawHub review metadata:

cd openclaw-chat-bridge
openclaw plugins install . --force

If the gateway will not take a managed restart (gateway port … is still busy, Capability: read-only), ask it to restart itself over the control plane instead of killing the process. It runs a preflight first and refuses when work is in flight:

{ "method": "gateway.restart.request", "params": { "reason": "load chat-bridge" } }

Note on packaging

This package deliberately has no dependencies and no peerDependencies, and its entry does not import from openclaw. The canonical definePluginEntry(...) helper is a pure normaliser that returns the same OpenClawPluginDefinition shape the loader is typed against, so importing it buys nothing but forces openclaw to be resolvable from the install directory — which on Windows makes the installer create a symlink and fail with EPERM unless Developer Mode or elevation is available. Exporting the plain object keeps installs working everywhere.

Configuration

The config key is the plugin id, which is the npm package name. Releases up to 0.1.2 were keyed by chat-bridge; that key is still read, so an upgrade keeps working, but new installs should use the name below.

{
  plugins: {
    entries: {
      "openclaw-chat-bridge": {
        enabled: true,
        config: {
          // Copy media out of outgoing replies into the per-session archive.
          archiveOutgoingMedia: true,
          // Reject uploads and skip archiving above this size. 0 = no limit.
          maxFileBytes: 0,
          // The `desktop` tool. On by default; see Desktop control below.
          desktopControl: true,
          // What screenshots are scaled to before the model sees them.
          desktopMaxWidth: 1280,
          desktopQuality: 80,
        },
      },
    },
  },
}

Desktop control

The plugin registers a desktop tool: screenshots, mouse and keyboard on the machine the gateway runs on. Install the plugin and the agent can use it — there is no node to pair and nothing else to set up.

screenshot · left_click · right_click · middle_click · double_click · triple_click
mouse_move · left_click_drag · type · key · scroll · cursor_position · wait

OpenClaw already has a built-in tool called computer, and this is not it. That one forwards every action to a paired node advertising computer.act, so nothing works until a device is paired. This one acts on the gateway's own host, which is why it lives here and why it is called something else — two tools with one name is a coin toss over which the model gets.

Coordinates are in screenshot pixels, never screen pixels. The model is shown a frame scaled down to something sendable and names a point on that; the plugin holds the scale and converts. Measured on a 3840x2560 screen: the raw frame is a 6.1MB PNG, and the same frame at desktopMaxWidth: 1280 is a 165KB JPEG.

No native modules. The driver is PowerShell reaching System.Drawing for the screen and user32.dll for input, which costs about 330ms per call including the C# compile — against a model turn measured in seconds. It is therefore Windows only: on any other platform the tool is not registered at all rather than offered and then failing on first use.

What you are turning on

desktopControl defaults to true, and it is worth being plain about what that means. Any session this gateway serves can take a picture of the screen and move the real mouse. If the gateway listens on anything but loopback, so does that. Set it to false to take it back:

config: { desktopControl: false }

Watching the screen

GET /api/v1/chat-bridge/screen?width=960&quality=60&fps=8
Authorization: Bearer <gateway secret>
→ 200 multipart/x-mixed-replace; boundary=ocframe

MJPEG: a boundary, a JPEG, repeat. It is how IP cameras have shipped live video for twenty years, and it needs no codec, no signalling and no second connection. A viewer stops watching by closing the socket — there is nothing to hang up.

width=0 means the screen's own resolution. A number cannot say "do not shrink it", and the largest size on offer is still a guess about somebody else's monitor.

GET /api/v1/chat-bridge/screen/info answers what is available without starting a capture, so a client can size its player first. While a capture is running it also reports the desktop's real pixel size, which is what lets a player show what width=0 is going to mean.

Measured on a 3840x2560 screen, quality 60, asking for 30fps so that nothing but the capture itself sets the rate:

| width | delivered | rate | a frame | per second | | -------- | ---------- | -------- | ------- | ---------- | | 640 | 640x427 | 8.4fps | 23KB | 191KB/s | | 1600 | 1600x1067 | 6.3fps | 128KB | 801KB/s | | 0 | 3840x2560 | 10.6fps | 631KB | 6.6MB/s |

Source resolution is the fastest setting, which is worth knowing before assuming it is the expensive one: the downscale is the slow step, not the encode, and at source there is no downscale. Asking for a smaller picture buys no extra frames — CopyFromScreen costs the same whatever it is scaled to afterwards — it buys bandwidth, and at source the bandwidth is the whole story.

Every frame is a whole picture, so the size of one tracks the pixel count (about 0.078 bytes a pixel between 640 and 1600, 0.065 at source, where nothing is resampled and the result is softer and compresses better) and what is on the screen: the same 960-wide frame measured 44KB over a quiet desktop and 77KB over a busy one. On a LAN none of this matters; over a phone connection, turn width down.

One capture feeds every viewer, and any request reconfigures it: the newest width, quality and fps win and the capture restarts at the new size. That is what lets a player put the controls on screen — the first rule here was that the first viewer's settings held, which meant the one person watching could never change anything. With two viewers it means the second one changes what the first sees. Frames are dropped rather than queued for a viewer whose socket is behind, which keeps a slow link live rather than lagging further and further behind.

The stream is part of desktop control and shares its switch: desktopControl: false turns off the tool and this route together.

Gateway methods

All methods ride the authenticated gateway WebSocket the app already holds open, so no extra port, credential or ingress is involved.

| Method | Scope | Purpose | | ---------------------------- | ----------------- | ---------------------------------------------------- | | chatbridge.info | operator.read | Capabilities, chunk ceiling, archive totals | | chatbridge.settings.get | operator.read | Read shared client settings | | chatbridge.settings.patch | operator.write | Shallow-merge settings; a null value deletes its key | | chatbridge.media.list | operator.read | Media indexed for one session, newest first | | chatbridge.media.get | operator.read | One entry plus its download path | | chatbridge.media.put | operator.write | Chunked upload | | chatbridge.media.delete | operator.write | Remove one entry and its bytes | | chatbridge.fs.mkdir | operator.write | Create one folder inside the agent workspace | | chatbridge.fs.rename | operator.write | Rename one folder in place |

Uploading

Gateway frames are capped (25 MiB by default) and base64 inflates payloads by about a third, so anything video-sized has to arrive in pieces. Send chunks with a shared uploadId; the last one commits:

// chunk 0..n-2
{ "sessionKey": "agent:main:ocapp:ab12", "uploadId": "u-1",
  "dataBase64": "…", "final": false }

// final chunk commits and returns the stored entry
{ "sessionKey": "agent:main:ocapp:ab12", "uploadId": "u-1",
  "dataBase64": "…", "final": true,
  "fileName": "clip.mp4", "mimeType": "video/mp4" }

chatbridge.info reports maxChunkBytes. Abandoned partial uploads are swept after 24 hours.

Downloading

Bytes are served over HTTP rather than the socket, so players can seek:

GET /api/v1/chat-bridge/media/<mediaId>
Authorization: Bearer <gateway secret>

The route honours Range, so video scrubbing works without buffering the whole file, and answers HEAD for size probes. It uses the gateway's own HTTP auth — the same shared secret the gateway accepts elsewhere. (A device token is not accepted on gateway HTTP routes; it returns 403.)

Resolving a transcript reference

A message that arrives with a file does not carry the bytes in its transcript row. Depending on how it was sent it leaves one of three traces:

  • __openclaw.media[].url = "media://inbound/<id>.png" — a media-store reference;
  • __openclaw.media[].fileName = "Z.png" — the name it was sent under, and nothing else, which is what a Discord image looks like;
  • MEDIA:/abs/path/to/file.png inside the agent's own reply — how an agent asks its channel to deliver a file.

None of them is addressable over the gateway's own HTTP surface. This route resolves all three:

GET /api/v1/chat-bridge/ref?path=media://inbound/<id>.png
GET /api/v1/chat-bridge/ref?path=/abs/path/to/anything/readable
GET /api/v1/chat-bridge/ref?name=Z.png&at=<message timestamp ms>
Authorization: Bearer <gateway secret>

media:// resolves inside the gateway's media store. An absolute path is read as given — see the 0.1.9 note below for why, and for what that grants. name looks for the staged copy the gateway made of an inbound file (input-<stem>---<uuid><ext>); at picks the copy stored nearest that moment when a name repeats.

The route is read-only. A reference that is not an absolute path is 404 unknown media reference; an absolute one that names nothing readable is 410 media bytes are gone, so a client can tell "that is not a reference" from "the file is gone". Range and HEAD work as they do on the media route.

Clients can detect support: chatbridge.info reports mediaRefPathPrefix from this version on, and omits it on older ones.

Project folders

The gateway has exactly one filesystem method, fs.listDir, and it only reads. So a companion app can browse to a project folder but cannot make one, and the folder has to already exist — created over SSH, or by asking the agent to run mkdir, which is a strange thing to do in order to pick a directory.

{ "method": "chatbridge.fs.mkdir",  "params": { "parent": "<abs dir>", "name": "NightRat" } }
{ "method": "chatbridge.fs.rename", "params": { "path": "<abs dir>",   "name": "NightRat" } }

Both answer { path, name } with the resulting absolute path. Deliberately small:

  • they only ever touch directories, never files;
  • mkdir creates one directory inside an existing parent and fails if anything is already there; rename renames one in place. Neither can move anything, and neither is recursive;
  • how far a caller reaches is the gateway's rule, not this plugin's: the same client.connect.scopes read fs.listDir makes. operator.admin writes anywhere on the host, exactly as it already browses anywhere; anything else is held to <stateDir>/workspace. A device that can already browse a directory is not made more dangerous by being able to create one in it;
  • the agent workspace root and a filesystem root cannot be renamed — the first because the running agent is standing in it, the second because C:\ is not a folder inside a folder;
  • the name must be a single path segment: no separators, no . or .., nothing Windows rejects, nothing ending in a dot or a space, 120 characters at most. It is validated rather than rewritten — a name that has to be repaired to be safe is not the name the caller meant.

chatbridge.info answers per caller — fsWritableAnywhere for an admin device, fsWritableRoots otherwise — so a client can grey the buttons out where they cannot work rather than fail at them.

Storage

Everything lives under the plugin's state directory:

<stateDir>/chat-bridge/
  settings.json                     shared client settings
  media/<sessionHash>/index.json    metadata for that session's media
  media/<sessionHash>/<mediaId>     the bytes
  uploads/<uploadId>.part           in-progress uploads

Plain files, on purpose. The SDK's openKeyedStore and openBlobStore are bounded caches — maxEntries and maxBytesPerNamespace are required, and overflow either evicts the oldest entry or rejects the new one. Chat history is supposed to stay retrievable, so silently dropping the oldest video is the wrong default. Files on disk keep history unbounded, make it inspectable with a file manager, and leave retention to the operator. Index writes go through write-then-rename, so an interrupted write cannot corrupt a session's index.

There is no automatic cleanup. Media accumulates until you delete it, through chatbridge.media.delete or by removing files directly. Set maxFileBytes to cap individual files, and keep an eye on the directory if agents produce a lot of video.

Archiving outgoing media

With archiveOutgoingMedia enabled (the default), the plugin registers a reply_payload_sending hook, inspects outgoing reply payloads for local file references, and stores a copy against the session before delivery continues.

Only local absolute paths and file:// URLs are archived; remote URLs are left to the channel that owns them. Archiving is best-effort and wrapped in a guard — a failure here logs and is swallowed, never blocking or altering a delivery.

Compatibility

package.json declares openclaw.compat.minGatewayVersion: "2026.9.2". That is the only gateway this has been built and tested against — not a claim that older ones are broken, but nothing below it has been tried, and a gateway may refuse to load the plugin on that declaration alone. Lower the floor yourself if you want to find out; expect to verify it.

Ships as plain ESM JavaScript with no build step, so the published package is the source you can read.

Verification

46 tests, all green against OpenClaw 2026.9.2:

  • 10 storage-layer tests (settings merge and delete, media indexing, key hashing for session keys containing : and /, chunk assembly, stats, sweep).
  • 20 pure-function tests (every Range form including suffix, open-ended, out-of-bounds and multi-range rejection; MIME mapping; RFC 5987 filenames; media-path extraction; remote-URL rejection).
  • 16 end-to-end tests against a live gateway: a real 5 MB upload split across three chunks, byte-exact download, sha256 match, 206 partial responses with correct content ranges, 416 on an out-of-range request, 401 without auth, and delete removing both bytes and index entry.

Changelog

0.3.0

Two session catalogs: Claude Code 桌面版 and Claude Code CLI -- your own conversations, which the gateway's own catalog does not show.

OpenClaw ships a claude catalog, and on a machine where the gateway drives Claude Code it lists the gateway's own agent runs: 109 of those here against 11 real ones, none of the real ones included. The transcripts say which program wrote them, on every line:

entrypoint: "claude-desktop"   the desktop app
entrypoint: "cli"              the terminal
entrypoint: "sdk-cli"          something driving it, i.e. the gateway

So these two catalogs read ~/.claude/projects and split on that rather than guessing from paths. sdk-cli is deliberately in neither.

Titles come from the desktop app's own records where there are any -- %APPDATA%/Claude/claude-code-sessions/*/*/local_*.json, keyed by cliSessionId, which is the transcript's name on disk. OpenClaw looks for those at <home>/Library/Application Support/Claude/... with no platform check, so on Windows it finds none.

Two things are left out on purpose: transcripts with nothing a person said (a slash command and its scaffolding is not a conversation -- two of the four CLI ones here), and the injected <local-command-caveat> and <system-reminder> blocks, which otherwise become the title of every session started with a command.

Importing is the gateway's own: a provider says what to call the session and copyToGatewaySession does the rest.

0.2.5

Watching keeps the screen awake, and wakes one that has gone dark.

The request is made from inside the capture process -- SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) -- so it lasts exactly as long as somebody is connected and there is nothing to release if the process is killed. A dark display is woken with a zero-pixel mouse move, which is what works on current Windows; it moves no cursor and presses nothing. screenWake: false turns that half off.

It cannot unlock anything. A locked machine wakes to its lock screen and stays locked, and the capture still fails with "the handle is invalid", because there is still no desktop being drawn. Keeping the display awake is what stops the machine reaching that state while somebody is watching; getting out of it needs a password, which is the user's to type.

Two things found while adding it. PowerShell reads 0x80000003 as a signed Int32 and then refuses to hand -2147483645 to a UInt32 parameter, so the flags go in decimal. And the setup half of the capture script sat outside its own error handler: that failure exited 1 with its reason in CLIXML stderr, which nothing on this side can read. The whole script is inside the handler now.

0.2.4

height asks for a size in lines, which is what "720p" means.

width was the only way to ask, and a client cannot turn 720p into a width without already knowing the shape of the screen. This host is 3:2 -- neither of the shapes anyone would assume -- so the companion app's picker had been labelling 1600 pixels across as "1600p" when it delivered 1067 lines, and could not offer 720p or 1080p at all.

height wins when it is set; width still works when it is not, so the config file and anything older are unaffected. width=0&height=0 is still the source resolution. Options are also normalised in the constructor now: a half-filled object left fields undefined, and undefined !== 0 made the next request look like a change and restart the capture for nothing.

0.2.3

A capture that cannot run now says so and stops trying.

Lock the workstation and CopyFromScreen throws at once -- there is no desktop to photograph -- so the capture exited immediately, every time. The restart was immediate and unlimited, which turned that into a PowerShell process spawned every couple of hundred milliseconds for as long as anybody had the viewer open.

It now backs off (0.5s, 1s, 2s, 4s), gives up after four tries, and ends the viewers' responses rather than leaving them on a silent stream that looks exactly like a desktop which happens not to be changing. The capture reports its own reason on a plain error line -- PowerShell's stderr arrives CLIXML-wrapped and unreadable -- and /screen/info carries it as lastError, so a client can say "the screen is locked" instead of quoting its own timeout back at you.

A new viewer clears the count: screens get unlocked.

0.2.2

width=0 streams the desktop at its own resolution. Zero is the sentinel because it is the one width that could never be a picture, and the capture now skips the downscale entirely when the output size is the screen size.

That turned out to make source the fastest setting rather than the slowest -- 10.6fps against 6.3fps at 1600 wide -- because the resample was always the expensive step, not the encode. The measurements are in Watching the screen above, including the claim they replace: a smaller picture does not buy more frames.

0.2.1

The stream settings can be changed by whoever is watching.

0.2.0's rule was "the first viewer's settings hold until the last one leaves", which reads reasonably and makes a quality control in a player a lie: the person holding the control is the viewer already connected, so moving it did nothing. A request now reconfigures the running capture, restarting it at the new size.

With two viewers the newest request wins and the other one's picture changes under them. That is the trade for a control that works, in a plugin for watching your own machine.

0.2.0

Adds a live screen stream: GET /api/v1/chat-bridge/screen, MJPEG, so a companion app can watch the desktop instead of asking for one screenshot at a time. See Watching the screen above.

One capture process is shared by every viewer and runs only while somebody is connected, so closing the connection is the whole of "disconnect" and a plugin nobody is watching costs nothing.

Two things this cost. -Command - had to go: passing a script on PowerShell's stdin runs it a line at a time like a REPL, so the capture loop -- a multi-line while block -- produced no output and exited 0. Everything now goes through -EncodedCommand, which hands the script over whole and streams its output. And normalizeOptions had to learn that absent is not zero: searchParams.get answers null for a parameter nobody sent, Number(null) is 0, and every request that named no size would have streamed 240 pixels wide at one frame a second.

0.1.11

0.1.10 registered the desktop tool and no model ever saw it.

api.registerTool is not enough on its own. A plugin tool reaches a conversation only when the manifest also claims it in contracts.tools and lists the active profile in toolMetadata.<name>.profiles — the gateway filters on both:

(plugin.contracts?.tools ?? []).filter(
  (name) => plugin.toolMetadata?.[name]?.profiles?.some((p) => p === profile))

Neither was declared, so the tool registered cleanly, logged cleanly, and was filtered out before it reached the model, which went on reporting that it had no way to control anything. Both are declared now, for all four profiles.

0.1.10

Adds the desktop tool: screenshots, mouse and keyboard on the gateway's own host. See Desktop control above for what it is, why it is not called computer, and what desktopControl: true hands out.

The point of putting it here rather than behind OpenClaw's node pairing is that installing the plugin is then the entire setup. The cost is that the plugin now does two unrelated things, which the description had to widen to admit.

Windows only, and dependency-free: PowerShell drives System.Drawing and user32.dll. Measured on this host — a 3840x2560 screen scales to a 165KB JPEG at 1280 wide, and a call costs about 330ms including the C# compile.

0.1.9

The ref route stops inventing a boundary, the same way the folder methods did in 0.1.5.

It served only the media store and the agent workspace, and 404'd everything else. That was wrong twice over. It did not match this plugin's own boundary — chatbridge.fs.* lets an operator.admin caller write anywhere on the host, while this route would not let the same caller read a file one directory outside the workspace, which is read stricter than write. And it did not match the gateway's: a MEDIA: directive delivered to a channel is read by OpenClaw and pushed out, so Discord receives a build output from anywhere on disk without complaint. Only a client that has to pull the bytes back through this route ever saw a limit — the same file, sent to two places, arrived in one.

An absolute path is now read as given. Be plain about what that grants: anyone holding the gateway secret can read any file this process can read, by naming its path. That was already true of writing.

Relative paths are still refused, and a missing file now answers 410 rather than 404, so the two cases are distinguishable from the outside.

0.1.5

The folder methods stop inventing a boundary and use the gateway's.

0.1.4 confined them to the agent workspace and offered fsWritableRoots to widen it. That rule was this plugin's own: OpenClaw has no mkdir at all, so there was nothing to inherit and a boundary got picked. It is now the same read fs.listDir makes — operator.admin writes anywhere on the host, exactly as it already browses anywhere, and anything else stays in the workspace. The config option is gone with the invented rule; how far a device reaches is decided by how it was paired.

0.1.4

Adds chatbridge.fs.mkdir and chatbridge.fs.rename, and reports fsWritableRoots from chatbridge.info.

Picking a project folder meant browsing to one that already existed, because fs.listDir is the gateway's only filesystem method and it only reads. These two close that gap for the agent workspace and nothing wider; see Project folders above for the boundary and the name rules.

0.1.3

Renames the plugin id from chat-bridge to openclaw-chat-bridge.

Installing 0.1.2 logged Plugin manifest id "chat-bridge" differs from npm package name "openclaw-chat-bridge"; using manifest id as the config key. OpenClaw matches a manifest id against the full package name or, for a scoped package, its basename — @openclaw/discord is how the bundled plugins get a short id. An unscoped name has nothing to shorten to, so the id has to be the whole thing.

Nothing on the wire moved: the methods are still chatbridge.*, the routes are still /api/v1/chat-bridge/media and /api/v1/chat-bridge/ref, and the archive still lives in <stateDir>/chat-bridge/. Config written under the old key is still read.

0.1.2

Adds GET /api/v1/chat-bridge/ref, which turns a transcript's media reference into bytes.

A companion app could see that a message carried a picture and had no way to fetch it: the core gateway serves no route for its media store, chat.history strips image payloads, and chat.message.get returns the row without them. The archive does not help either — it only holds what the outgoing-media hook copied. The bytes are on disk the whole time, so this reads them, from two directories and no others.

chatbridge.info now also reports mediaRefPathPrefix, so a client can tell a gateway that can serve references from one that cannot.

0.1.1

Documentation only; no runtime change.

The install instructions named --accept-capabilities unconditionally. That option exists on 2026.9.2 but not on older gateways, which reject the whole command with does not recognize option "--accept-capabilities" rather than ignoring it. The command is now shown without the option, with a note on when it applies. The local-checkout example dropped it too.

0.1.0

First release.

License

MIT