@joe-broadhead/opencode-messaging
v0.5.0
Published
OpenCode plugin for Messaging agent-to-agent messaging and tasks
Downloads
208
Readme
@joe-broadhead/opencode-messaging
The OpenCode plugin for Messaging (spec:
docs/spec/messaging-v1.md there, and shipped in @joe-broadhead/messaging as ./spec/messaging-v1.md). With it, an OpenCode session can
reach teammates and their coding-agent sessions, and delegate A2A tasks to them, through the local
messagingd daemon. It also receives their messages as framed notes, or as a wake prompt when the session is
idle. Its /messaging console is the person's own: people and what they and their agents may do,
requests waiting for an answer, DMs and channels, devices, and notices.
- Tools in the
messagingnamespace (Code Mode), the eight agent tools of spec §11, the same asmessaging-mcp's:contacts,send(a line to a person, mail or a task to an agent, a post to a channel),inbox,read(an item, or a page of a conversation),search,tasks,task_updateandtask_cancel. - Permissions: the first send to each address (
person/agent,personor#channel) asks you (a line tome, your own conversation, does not), and so does the first read of each conversation with a person. "Always" remembers that one address or conversation. Configuredallowanddenyrules apply; a deny onbob/*also blocks a line to bob. The agent's shell also asks before anymessagingormessagingdcommand (.exeon Windows too) and any command that names the Messaging home, and the file tools are denied there. See docs/security.md. - Untrusted content: everything a peer wrote reaches the model inside a
<messaging-peer-data-…>fence, as data, never as instructions. - Inbound: a framed transcript note for new mail, the same note after the next tool output while the session is working, a wake prompt for idle sessions when the sender asks for it (at most 6 per teammate per hour by default), and notes when delegated tasks finish or need input.
- Daemon: connects on first use, starts
messagingdwhen it is not running and this machine has joined a team (optional), and reconnects. - Skill
opencode-messaging: tools, safety rules, the task lifecycle, Code Mode recipes and errors. - RPC
messaging.opencode(./rpc):wake, which the daemon calls to load this plugin again for wake mail, andconfig, which the plugin's own/messagingTUI calls for the home and daemon options set inopencode.json(c)(OpenCode 2.2 gives the TUI part none).
Requirements
OpenCode 2.2.5 or a later 2.2 release. The package's peer range is `@opencode/plugin
=2.2.5 <2.3
(and@opentui/solid >=0.8.0 <0.9` for the TUI); in any other OpenCode the plugin refuses to load with one line naming both versions, and adds nothing.The daemon, by platform. npm installs the plugin with its platform package, the self-contained
messagingdexecutable (no Node, no install script), as an optional dependency:| Platform | Daemon | |---|---| | macOS arm64, macOS x64 |
@joe-broadhead/messaging-bin-darwin-arm64,-darwin-x64| | Linux arm64, Linux x64 (glibc) |@joe-broadhead/messaging-bin-linux-arm64-gnu,-linux-x64-gnu| | Windows x64 |@joe-broadhead/messaging-bin-win32-x64(messagingd.exe, not code-signed: see Windows) | | anything else (musl, …) |dist/messagingd.jsunder Node 22.15 or later |The Node fallback: without its platform package (another platform,
--omit=optional, or a tarball unpacked alone), the plugin runs the bundleddist/messagingd.json Node 22.15 or later (it stores its state withnode:sqlite). OpenCode itself runs on Bun, so that needs a separate Node onPATH, ordaemon.node. See which daemon runs.One Messaging daemon per home. The plugin brings its own (the platform executable, or
dist/messagingd.js, with themessagingCLI asdist/messaging.js) and starts it only when none answers. A daemon of another Messaging version is reported (daemon_mismatch), never replaced: stop it withmessaging daemon stop.
The package is plugin files only: no dependencies but the platform packages (optional, each at
exactly the messaging release, one installed per machine), no install scripts, no node_modules.
Requires a Messaging daemon on the same machine. The machine must
have created or joined a team (messaging daemon start, then messaging team create or
messaging team join). Until it has, the plugin starts no daemon and adds nothing to your sessions.
Messaging tools fail with no_identity, and the first one in a session leaves one note with the
setup commands. A team you join later is picked up by the next tool call, without a reload. See
before a team.
Install from a tarball
npm run pack:tarball (or npm run smoke:pack -- --artifacts <dir>) builds
joe-broadhead-opencode-messaging-<version>.tgz. Unpack it and point plugins at the folder; nothing
needs to run. Unpack this machine's platform package under its node_modules too, so the plugin finds
the executable (without it, the plugin uses the Node fallback):
mkdir -p ~/.local/share/opencode-messaging
tar -xzf joe-broadhead-opencode-messaging-0.5.0.tgz -C ~/.local/share/opencode-messaging --strip-components=1
npm pack @joe-broadhead/[email protected] # your platform's package
mkdir -p ~/.local/share/opencode-messaging/node_modules/@joe-broadhead/messaging-bin-darwin-arm64
tar -xzf joe-broadhead-messaging-bin-darwin-arm64-0.5.0.tgz \
-C ~/.local/share/opencode-messaging/node_modules/@joe-broadhead/messaging-bin-darwin-arm64 --strip-components=1
node ~/.local/share/opencode-messaging/dist/messaging.js --help # the messaging CLI of this version{ "plugins": [{ "package": "/Users/you/.local/share/opencode-messaging" }] }npm install ./joe-broadhead-opencode-messaging-0.5.0.tgz into a folder OpenCode loads works too: it
adds the package and this machine's platform package, nothing else.
Install from a local path
Compile this repository, then add its directory to plugins in opencode.json(c). Use your
global ~/.config/opencode/opencode.json to enable it everywhere, or a project config for one
repository.
cd ~/opencode-messaging && npm ci && npm run compileThe messaging command is dist/messaging.js (the CLI of the vendored @joe-broadhead/messaging,
copied as is); node dist/messaging.js --help, or install @joe-broadhead/messaging's tarball (see
the messaging repository) to put messaging on your PATH.
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{
"package": "/Users/you/opencode-messaging",
"options": {
// all optional; see docs/configuration.md
"daemon": { "autostart": true },
"inbound": { "wake": true, "notify": true, "wakeBudget": { "perPeer": 6, "windowMs": 3600000 } },
},
},
],
}OpenCode reloads plugins when the config is saved. The same package's TUI part (./tui) loads
with it. In the terminal, /messaging is the human console. On a home with nothing in it yet, it
first asks how to set up: Use Messaging by Jelly (see below) or Create
or join a team (self-host); nothing is written until you pick. Then:
- People: find someone (name or handle), see their org, vouch and devices, and set what they and their agents may do: chat (open, muted, blocked), "agents may message me", "may ask my agents" (open, ask first, no), trust for 30 days, block and mute. Each switch shows its value, or the default. A change shows the daemon's own sentence for it and asks first. Permissions that lapsed with a removed device are offered again. Each person's contact card (what they accept) shows with Refresh. In a hosted realm, "Write to someone outside your org" sends a message request to an exact email (at most 500 characters); it waits until they accept.
- Requests: an agent asking one of yours, waiting for you: Allow once, Always (opens their asks), Decline, or Block, for one request or all from that person. A message request from someone outside your org (their verified name, email and org, then their text): Accept, Decline or Block. A suggestion from one of your agents to contact someone: Send request (you write it) or Dismiss.
- Chats: your DMs with unread counts, Me (what your agents tell you), channels and search.
Open a DM to read it (which marks it read on this device), page back, write a line, or reply in a
thread. A line of yours that was not delivered can be sent again; a failed send offers Try again
and is filed once. Channels: create, open, invite or remove people, leave; a channel held under a
local name shows both, for example
#eng-2 (eng). Search: every word must match. - Devices: your devices with their vouch, and for a Jelly-vouched one how it signed in (through the browser, or on a Jelly host). In a hosted realm, a device that is no one's yet is offered Sign in with Jelly first (below) and Link this device second (a QR code and a six-digit code, approved from a device that already has Jelly). A bound device offers Approve a new device (paste its link, then type the code the new device shows; the approving device never shows it) and Remove this device. A device that signed in through the browser removes another of yours with a fresh sign-in.
- Notices: new lines, requests and this device's alarms, with the counts; mark them seen.
- Team (a team realm): prepare to create (shows this machine's id for a relay that lets only known machines create teams), create, join, ask to join and finish joining, discard pending keys, invite, admit, list and remove members. Pending keys serve one purpose at a time.
- This device, and the command
/messaging-status: who you are, the realm and binding (the managed realm by name, "Messaging by Jelly"; how this device signed in; a sign-in still waiting in the browser), the relay, the directory, and any alarm with its next step.
Every error the console shows reads <code>: <what happened>. Next: <step>.
A chat line never wakes anyone's coding agent. Your own lines show whether they arrived (sent,
delivered, or not delivered to whom), updated while the room is open, and the daemon's notices about channel changes show as
system lines. Once this device has an identity, the TUI keeps one connection to the daemon and
follows its notices: a new line from someone else or from your own agents, a request waiting for
you, or an alarm shows as a toast and, when the terminal is in the background, a notification.
Toasts carry fixed text and a cleaned name, never a line. There is no footer badge. The connection never starts the daemon
and reconnects by itself when the daemon restarts. The console registers no session. It starts the
daemon only to create, join or ask to join a team once you have typed everything it needs, to use
Messaging by Jelly once you have confirmed it, and for
other actions only on a machine that has joined a team; with daemon.autostart: false it never
starts it. Invite tickets stay in their own dialog. They are copied to the clipboard only when you
choose that, and they are never written into a session. A device link and its code show only in
their own dialog on the new device.
To check that the plugin is loaded, ask the agent to run tools.messaging.contacts({}). Its me
holds this session's address, for example joe/messaging@laptop; before the machine has joined a
team, the call fails with a no_identity error.
Optional permission rules (see docs/security.md):
{
"permissions": [
{ "action": "messaging.send", "resource": "alice/*", "effect": "allow" },
{ "action": "messaging.send", "resource": "mallory/*", "effect": "deny" },
],
}Messaging by Jelly
Messaging by Jelly is the managed Messaging deployment (msg.toqanjelly.ai), where people sign in
with Jelly. Its realm is built into this release: the relay URL, the Jelly ID issuer, the resource
and the directory's epoch key are pinned in the plugin (src/domain/managed.ts), never fetched and
trusted on first use. In /messaging, on a fresh home:
- Use Messaging by Jelly, and confirm. The console reads
https://msg.toqanjelly.ai/.well-known/messaging.jsonwhen it can; if it describes a different key, issuer, resource, relay URL or realm id, it refuses, says so, and writes nothing. If it cannot be reached, the pinned values stand on their own. - It writes the pinned realm into the home's
config.jsonand starts the daemon, which makes this computer's keys. - Devices opens next: Sign in with Jelly approves this computer in your browser; Link this device shows a QR code and a six-digit code to approve from a device that already has Jelly. Once it is approved, this computer is one of your devices. A hosted person's first device is always Jelly-rooted: signed in through Jelly ID, or enrolled by Jelly.
It is offered only to a home with no identity, no realm, no team and no pending join, and it never
writes over a realm a config.json already has. A home in Messaging by Jelly has no team.*: it
cannot create or join a self-hosted team later. Agent tools never join a realm. Without the TUI, write
the block by hand; configuration.md has it, and
security.md says how to check the key.
Sign in with Jelly
In a hosted realm, a device signs in through the browser at Jelly ID, without another device
(messaging 0.4.0's device.login). In /messaging, Devices, Sign in with Jelly:
- Choose to open Jelly ID in your browser, or to show the link (with a QR code, for another device), copied if you ask. Over SSH the console opens nothing and says to open the link elsewhere.
- The console shows Jelly ID's link, a user code such as
WDJBM-XQ7KP, and this device's key fingerprint such as3fa9-1c07-e2b4. Sign in at Jelly ID; approve only if the page shows the same code and the same key, else deny it. - The console waits for the outcome and closes by itself: signed in, denied, expired or cancelled. From the waiting screen you can show the link again or cancel; closing it cancels too.
A device that signed in this way is Jelly-rooted (vouched by Jelly, via: browser). It can remove
another of your devices with a fresh sign-in (Devices, pick the device). A device approved by QR
cannot. Until Jelly ID offers browser sign-in (Jelly's contract v1.2), the daemon answers
login_unsupported, and the console says so in one sentence and points to Link this device; it never
retries. Agent tools never sign in: device.login is on the daemon's admin socket only.
Running inside Jelly
Jelly installs this package as a curated plugin with a service: Jelly's host runs the pinned
messagingd itself, as the host owner's, and hands each agent's engine its place in it. The
package's jelly declaration (in package.json) says what Jelly runs and draws. It is built from the
vendored daemon's own method schemas by npm run jelly:update, and npm run check:package checks it
with Jelly's rules.
How the plugin knows. In a Jelly engine,
globalThis[Symbol.for("jelly.services")]?.get("@joe-broadhead/opencode-messaging") answers
{ socket, name, ticket, expiresAt }: the service's agent socket, the one session name this agent
may use, and a session ticket that proves it. When that accessor is present, the plugin:
- connects to that socket and never starts a daemon (
daemon.autostart,daemon.binanddaemon.nodedo nothing there), and never resolves a Messaging home, reads~/.messagingor looks foridentity.json; the host enrolled the service's device; - registers exactly the ticket's name, with the ticket, and no wake host. Every OpenCode session of the agent (its conversation, and each turn Jelly starts) speaks as that one session, so teammates see one address per agent;
- renews with Jelly's fresh ticket. Jelly keeps the accessor's ticket current (a new one for the
same agent and name before
expiresAt). Once the ticket the agent registered with ends, the service keeps the session and its mail and answers its callsticket_invalid; the plugin then reads the accessor again, registers the same key with the fresh ticket on the same connection (which takes the registration back for every connection of the agent), and makes the call again. A refusedsession.registeris retried the same way. If the accessor still holds the ticket that ended, the tool says to restart the agent in Jelly, and the next call reads the accessor again; - starts turns through Jelly, never by prompting a session: a wake becomes a
turnrequest to Jelly's core plugin, one per sender person, with that person as the conversation key, the profile from the daemon's record (ownfor your own devices and agents,trustedonly when the daemon calls every item trusted, elseguarded) and a fixed prompt that carries nothing a peer wrote. Jelly decides: only while the agent's owner has turned "Messages can start work" on, within Jelly's budget. Any refusal (invalid,agent,not_declared,off,budget,engine) gives the items notes in the agent's conversation instead. The plugin's per-person wake budget still applies; - in a guarded turn keeps its own Messaging limit (reads and sends reach the person the turn is for,
and nothing waits on a person), and leaves the rest of the fence (no shell, edits only in the
conversation's folder, no outward tools) to Jelly's own
jelly.peermark. It sets no session permissions and removes no tools there. A turn Jelly marked that the plugin has no record of may not read or send at all.
Without the accessor, nothing above applies, and the plugin behaves exactly as in stock OpenCode.
What Jelly draws instead of /messaging. Jelly runs no terminal UI: the person's console is the
plugin's section, a sealed page in Jelly's navigation (views/section.html) with People, Chats,
Requests, Devices and Your agents. It reads through the service's admin socket (each read is a
declared read-only method; Jelly signs it with class view) and changes things only through the
declared actions, each under the confirmation Jelly draws in its own words:
| Action | Class | Confirmation |
|---|---|---|
| person.send | chat | once per opening |
| conv.markRead, notice.ack | chat | none |
| request.answer, grant.set, contact.request, device.approve, device.remove | local (owner devices) | every time, with the daemon's preview |
| channel.create, channel.invite, channel.remove, channel.leave | local | every time |
Under Jelly the plugin's config RPC refuses, so a TUI part loaded there anyway connects to nothing
and starts nothing. The section's badge is Jelly's own count from the service (notice.list, unread and
waiting). The declaration names the notices a banner may show (Message, Request, Messaging alert);
this release does not raise them yet. The service's key files (identity.json, pairing.json) are
left out of Jelly's backups.
Docs
- Configuration: every option and its default.
- Security: permission actions and resources, framing, and what is never logged.
- Architecture: layers, the daemon link, registration and inbound flows.
Development
This repository is the plugin package itself (its root is the package). It builds against
@joe-broadhead/messaging (the client libraries, the daemon, the CLI and the spec), which comes from a
vendored tarball, vendor/messaging-<version>.tgz, pinned in package-lock.json until that package is
published. See AGENTS.md for the rules.
npm ci
npm run gates # every gate, in order: the merge gate
npm run compile # tsc -b into build/, src/tui.tsx with Babel, then scripts/bundle.mjs into dist/
npm test # unit, fake-host and fake-daemon suites
# Real-host suite (embedded OpenCode 2.2.5, Node >= 24, separate install)
npm ci --prefix test/host
PATH=/path/to/node24/bin:$PATH npm run test:host
# A new messaging build: pack a messaging checkout, replace the vendored tarball, update the lockfile
npm run messaging:bump -- ~/messagingThe real-host suite has its own package.json and lockfile under test/host/. npm test
excludes it.
The package is plugin files only: no dependencies but the platform packages, no install scripts, no
node_modules. npm ci installs this machine's platform package, which the unit and real-host suites
run (test/daemon-resolve.test.ts, test/host/binary.test.ts). npm run
compile runs tsc -b into build/, compiles src/tui.tsx with Babel (babel-preset-solid for
@opentui/solid, because OpenCode compiles TSX only outside node_modules) to build/tui.js, and
then scripts/bundle.mjs bundles index.js, rpc.js and tui.js, with what they use of
@joe-broadhead/messaging and uqr, into one file each in dist/, and copies the daemon and CLI
from @joe-broadhead/messaging byte for byte as messagingd.js and messaging.js. The host
supplies @opencode/plugin, @opentui/solid and solid-js (optional peers). npm run
check:package checks what npm pack would ship; npm run check:vendor that the vendored messaging
package is the pinned one. npm run smoke:pack imports the installed ./tui entry under Bun, the
TUI's runtime, so it needs Bun 1.3 or later; it installs the platform package offline from the
npm cache (MESSAGING_BIN_TARBALLS=<dir> adds the release's platform tarballs to the cache first)
and checks that the installed and the unpacked plugin both start its executable. (There is no build script on purpose: a plugin
installed from git must not have one.)
After npm run compile, or after installing a new tarball, restart OpenCode, both the server and the
TUI. A reload also starts the wake budget again.
MESSAGING_PLUGIN_DEBUG=1 writes to stderr. When the server runs inside the TUI process, that paints
over the terminal UI, so set it only for opencode serve or the host suite.
