openclaw-chat-bridge
v0.3.0
Published
OpenClaw plugin: shared settings, durable session media, and desktop control for companion chat apps.
Maintainers
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 restartThe 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 . --forceIf 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 · waitOpenClaw 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=ocframeMJPEG: 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.pnginside 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;
mkdircreates one directory inside an existing parent and fails if anything is already there;renamerenames 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.scopesreadfs.listDirmakes.operator.adminwrites 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 uploadsPlain 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
Rangeform 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,
206partial responses with correct content ranges,416on an out-of-range request,401without 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 gatewaySo 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
