@emiltsoi/openclaw-mesh
v0.2.6
Published
Stateful, Ed25519-signed agent-to-agent mesh messaging for OpenClaw with Hermes bridge support
Readme
openclaw-mesh
Stateful, Ed25519-signed agent-to-agent mesh messaging for OpenClaw. Use it to let one OpenClaw agent send messages to another OpenClaw agent, or to bridge OpenClaw agents to Hermes mesh peers. All traffic is carried over the [mesh] envelope format with Ed25519 signatures, durable inbox persistence, and SSRF-protected outbound delivery.
What it does
openclaw-mesh turns an OpenClaw agent into a mesh peer:
- Inbound: receives a
[mesh]webhook, verifies the sender's Ed25519X-Mesh-Signature, writes the message to a durable inbox, and triggers an in-process OpenClaw agent turn in the configured session. - Outbound: exposes the
mesh_sendtool so the agent can Ed25519-sign and POST[mesh]envelopes to any peer discovered from the shared mesh vault. - Discovery: exposes
mesh_list,mesh_register,mesh_deregister,mesh_sync, andmesh_publishso agents can discover, register, and deregister themselves without leaving the chat.
Because both OpenClaw and Hermes agents can share the same mesh vault and envelope format, the plugin works in two modes:
- OpenClaw-only mesh — two or more OpenClaw agents register in the same vault and send
[mesh]envelopes to each other. - Hermes bridge — OpenClaw agents exchange envelopes with Hermes mesh peers.
Architecture
OpenClaw agent A OpenClaw agent B (this plugin)
│ mesh_send(to=B) │
│ [mesh] envelope + Ed25519 sig │
└───────────────webhook──────────────▶│
├─ verify Ed25519 against sender's public_key
├─ write to mesh-inbox.jsonl
├─ optional mirror (telegram/cli)
└─ runEmbeddedAgent(target session)The same flow works when the sender is a Hermes mesh agent.
The plugin:
- Verifies the inbound
X-Mesh-SignatureandX-Mesh-Timestampheaders using the sender's cachedpublic_keyfrom the mesh vault (falling back to the optionalmesh-peer-registry). - Parses and validates the
[mesh][from:...][to:...][id:...][action:...][reply:...]envelope (plus the optional[session:...]/[from_session:...]tokens from the session-selector cut). - Writes the message to a durable inbox (
/tmp/openclaw-mesh/mesh-inbox.jsonlby default). - Optionally mirrors the inbound message to
telegramorcli. - Resolves the real session UUID for the target session key (OpenClaw 2.0 sqlite
session_nodes.current_session_id), wraps the embedded run in the gateway's independent root-work admission (runWithGatewayIndependentRootWorkAdmission), and callsapi.runtime.agent.runEmbeddedAgent(...)so the configured session wakes and processes the turn. - Mirrors the embedded run's final assistant output to Telegram (when
mirrorOutbound: "telegram") instead of auto-replying to the envelope sender — matching Hermes-side behavior.
OpenClaw 2.0 change set
OpenClaw 2.0 (2026.8.x) moved session storage from JSONL files to a sqlite store (session_nodes, session_windows). This changed the embedded-run contract in three ways, all handled by the plugin:
Session UUID resolution (writer admission). The 2.0 runtime's writer admission (
claimAgentSessionWriter) compares thesessionIdpassed torunEmbeddedAgentagainst the store entry's real session UUID (session_nodes.current_session_id). Passing a key-derived id (e.g.'main'fromagent:main:main) now fails withSession changed before writer admission. The plugin resolves the real UUID from the store before the run (resolveSessionIdForRun), falling back to the key-derived id on pre-2.0 (JSONL-era) stores for backward compatibility.Key-tolerant fallback (defense-in-depth). When the stored session id is not UUID-shaped (a legacy JSONL-era row that survived migration),
resolveSessionIdForRunreturns the full session key rather than forcing a UUID into the admission check. This does not by itself fix 2.0 admission — the real fix for a key-shaped legacy session is minting a fresh UUID via the runtime's owncreateSessionEntryWithTranscript— but it keeps the plugin from making a bad situation worse on any future key-shaped row.Root-work admission wrap (the "Gateway is draining" saga). The 2.0 runtime only admits subordinate (root-less) work when the gateway's suspend phase is
accepting— which is why embedded mesh runs fired fire-and-forget failed intermittently between turns (and why Telegram turns, which always hold a gateway root, never failed). The plugin now wraps every embedded run inrunWithGatewayIndependentRootWorkAdmission, giving the run its own independent root — the same wrapper the runtime itself uses for delivery-queue drains, heartbeat wakes, and restart recovery. This makes embedded runs reliable between turns, not just during the post-boot accepting window.
Telegram mirror behavior
Since 0.2.5, embedded-run output is mirrored to Telegram (when mirrorOutbound: "telegram" is configured) instead of auto-replying to the envelope sender. This matches Hermes-side behavior: the mesh message arrives, the agent processes it in its session, and the visible output lands in the agent's Telegram chat — not as a mesh reply to the sender. The reply: envelope field remains respected for delivery receipts.
Installation
Published on ClawHub:
openclaw plugins install clawhub:@emiltsoi/openclaw-meshOr install from the local checkout for development:
cp -r /path/to/openclaw-mesh ~/.openclaw/workspaces/<agent>/plugins/openclaw-mesh
npm install
npm run buildThen enable it in ~/.openclaw/workspaces/<agent>/openclaw-<agent>.json:
{
"plugins": {
"load": {
"paths": [
"/home/emil/.openclaw/workspaces/<agent>/plugins/openclaw-mesh"
]
},
"entries": {
"openclaw-mesh": {
"enabled": true,
"config": {
"routingAgent": "emts",
"targetSessionKey": "agent:main:main",
"targetAgentId": "main",
"sourceChannel": "mesh",
"sourceTo": "",
"meshVaultPath": "",
"mirrorInbound": "none",
"mirrorOutbound": "none",
"debug": false,
"allowLoopback": false,
"deliveryRetries": 3,
"deliveryBackoffMs": 1000,
"deliveryTimeoutMs": 15000
}
}
}
}
}Restart the OpenClaw gateway after changing source or openclaw.plugin.json.
Configuration Options
| Option | Default | Description |
|--------|---------|-------------|
| routingAgent | emts | Mesh agent name this instance accepts messages for. |
| targetSessionKey | agent:main:main | Target OpenClaw session key. |
| targetAgentId | main | Target OpenClaw agent ID. |
| sourceChannel | mesh | Channel attributed to the injected turn. |
| sourceTo | envelope.from | Channel target/to for the injected turn. |
| model | config.agents.defaults.model.primary or deepseek/deepseek-v4-pro | Optional provider/model override for the embedded run. |
| meshVaultPath | $OPENCLAW_STATE_DIR/mesh or /tmp/openclaw-mesh | Path to the mesh vault root (the directory that contains mesh/agents). |
| inboxPath | /tmp/openclaw-mesh/mesh-inbox.jsonl | Durable inbox file path. |
| mirrorInbound | none | Where to mirror inbound mesh messages: none, telegram, or cli. |
| mirrorOutbound | none | Where to mirror outbound mesh messages: none, telegram, or cli. |
| debug | false | Emit verbose debug logs to stderr and /tmp/openclaw-mesh-debug.log. |
| allowLoopback | false | Allow outbound webhook deliveries to loopback/private addresses. |
| privateNetworkPolicy | deny | Override for private network handling. Set to allow, warn, or deny. If allowLoopback is true, loopback deliveries are allowed regardless of this value. |
| deliveryRetries | 3 | Number of outbound webhook delivery attempts. |
| deliveryBackoffMs | 1000 | Initial retry backoff in milliseconds. |
| deliveryTimeoutMs | 15000 | Per-attempt delivery timeout in milliseconds. |
| registryUrl | — | URL of the mesh-peer-registry server (e.g. https://registry.example.com). Used by mesh_sync and mesh_publish. |
| privateKeyPath | ~/.mesh/keys/<routingAgent>.pem | Path to the Ed25519 private key PEM. Generated on first use if missing. |
| signTimestamp | true | Include X-Mesh-Timestamp in the signed outbound payload. |
Migration note (v0.2.2): timestamp-covered signatures are now required on inbound verification. The body-only signature fallback was removed (U12), so any legacy peer with
signTimestamp: falsemust migrate to signing"<ts>\n<body>"(theX-Mesh-Timestampheader value followed by a newline, then the exact request body). We own both ends of the mesh, so the fallback was dropped rather than kept. |allowInsecureRegistry|false| Allowhttp://registry URLs. Not recommended for production. | |registryPin| — | SHA-256 hex digest of the registry server certificate SPKI for TLS pinning. | |auditLogPath| — | Optional JSON-lines audit log file for mesh traffic. Falls back toOPENCLAW_MESH_AUDIT_LOG. |
The Ed25519 private key is loaded in this order:
pluginConfig.privateKeyPathMESH_PRIVATE_KEY_PATHenvironment variable~/.mesh/keys/<routingAgent>.pem(generated on first use)
Inbound / Outbound Mirroring
Mirroring lets you observe mesh traffic without opening the session transcript. It is controlled independently per direction:
mirrorInbound— applied when a mesh webhook is received.mirrorOutbound— applied whenmesh_sendposts to a peer webhook.
Supported values:
| Value | Behaviour |
|-------|-----------|
| none | No mirroring (default). |
| telegram | Send via the Telegram Bot API using config.channels.telegram.botToken / chatId. |
| cli | Write to stdout, which appears in the gateway logs. |
Mesh Vault Discovery
The plugin can discover peers from a file-based mesh vault (default) or from an optional mesh-peer-registry server.
$OPENCLAW_STATE_DIR/mesh/mesh/agents/
├── agent0/
│ └── identity.yaml
├── emts/
│ └── identity.yaml
└── linda/
└── identity.yamlFive tools are exposed:
mesh_list— list discoverable peers withname,platform,a2a_url,webhook_url, andpublic_key. No secrets are leaked.mesh_send(agent, message, action?, reply?, id?, thread_id?)— resolve a peer, Ed25519-sign a{"from": "<routingAgent>", "text": "[mesh][from:...]..."}payload with the sender's private key, and POST it to the peer'shermes_webhookURL. Useidorthread_idto preserve the mesh thread id on replies.mesh_register(name?, description?, role?, platform?, a2a_url?, webhook_url?, public_key?, allow_loopback?)— write or update this agent'sidentity.yamlin the mesh vault so peers can discover it. Defaults are derived fromroutingAgent, the OpenClaw gateway config, and a generated Ed25519 keypair.mesh_deregister(name?, force?)— remove this agent from the local vault and, if configured, from the mesh-peer-registry.mesh_sync(name?, registry_url?)— fetch a peer (or all peers) from the mesh-peer-registry and cache it in the local vault.mesh_publish(name?, url, role?, description?, ttl?, registry_url?)— publish this agent's webhook URL and Ed25519 public key to the mesh-peer-registry.
Agent listing
mesh_list returns a JSON object like:
{
"count": 2,
"peers": [
{
"name": "agent0",
"platform": "hermes",
"a2a_url": "http://127.0.0.1:41808/a2a",
"webhook_url": "http://127.0.0.1:8645/mesh/receive",
"public_key": "-----BEGIN PUBLIC KEY-----\n...",
"description": "Hermes agent zero",
"role": "operator"
},
{
"name": "emts",
"platform": "openclaw",
"a2a_url": "http://127.0.0.1:18860",
"webhook_url": "http://127.0.0.1:18860/plugins/openclaw-mesh/webhook",
"public_key": "-----BEGIN PUBLIC KEY-----\n...",
"description": "OpenClaw mesh peer",
"role": "mesh_peer"
}
]
}Private keys are never exposed in the listing; they are only used internally when mesh_send signs an outbound message.
meshVaultPath is path-neutral: ~ and relative paths are resolved through OpenClaw's api.resolvePath or manual ~ expansion, so you can point the plugin at any vault on any system. It points to the mesh vault root (the directory that contains mesh/agents), and the plugin appends mesh/agents internally.
It resolves in this order:
pluginConfig.meshVaultPath— mesh vault root (supports~and relative paths)MESH_VAULT_PATHenvironment variable — mesh vault root (supports~and relative paths)HERMES_HOME(with/profiles/<name>stripped) +/fleet/mesh/agents- Fallback to
$OPENCLAW_STATE_DIR/mesh(or/tmp/openclaw-meshifOPENCLAW_STATE_DIRis not set)
Mesh Peer Registry
For a centralized, multi-host discovery backend you can use mesh-peer-registry (also on PyPI):
pip install mesh-peer-registry
mesh-peer-registry --port 8646 --store ~/.mesh/registry.sqliteThen point openclaw-mesh at it:
{
"registryUrl": "https://registry.example.com",
"privateKeyPath": "~/.mesh/keys/emts.pem",
"registryPin": "sha256-hex-of-server-certificate-spki"
}The registry is optional: the local vault is the runtime source of truth. mesh_sync pulls peers from the registry into the vault, and mesh_publish pushes this peer's public key and webhook URL to the registry.
The registry is language-agnostic: Hermes peers and OpenClaw peers can share the same mesh-peer-registry instance. See the mesh-peer-registry README for API details.
Registering an OpenClaw agent
Agent-friendly way: mesh_register
The agent can register itself by calling the mesh_register tool. In most cases just call:
mesh_register()The plugin fills in:
namefromroutingAgent(orMESH_AGENT_NAME, defaulting toemts)a2a_urlfrom the OpenClaw gateway config (http://127.0.0.1:<port>)webhook_urlas<a2a_url>/plugins/openclaw-mesh/webhookpublic_keyfrom a generated or reused Ed25519 keypair atprivateKeyPath
Optional overrides:
mesh_register(name="emts", description="OpenClaw mesh peer", role="mesh_peer", platform="openclaw")mesh_register is idempotent — calling it again overwrites the same identity.yaml with updated values. The vault directory is created with 0o700 permissions and the identity.yaml file with 0o600 permissions.
Manual way
If you prefer to write the file outside the agent turn, create a directory and identity.yaml under <mesh-vault-root>/mesh/agents/<agent-name>/:
mkdir -p $OPENCLAW_STATE_DIR/mesh/mesh/agents/emtsThen write $OPENCLAW_STATE_DIR/mesh/mesh/agents/emts/identity.yaml:
id: emts
name: emts
kind: openclaw-agent
role: mesh_peer
description: OpenClaw mesh peer
a2a_url: http://127.0.0.1:18860
webhook_url: http://127.0.0.1:18860/plugins/openclaw-mesh/webhook
allow_loopback: true
transports:
hermes_webhook:
protocol: hermes-webhook
url: http://127.0.0.1:18860/plugins/openclaw-mesh/webhook
auth:
public_key: |
-----BEGIN PUBLIC KEY-----
<sender's-ed25519-public-key>
-----END PUBLIC KEY-----Set allow_loopback: true when the peer runs on the same host and you want the plugin to allow deliveries to 127.0.0.1/private addresses.
Envelope Format
The [mesh] envelope is shared with hermes-mesh:
[mesh][from:<sender>][to:<recipient>][id:<uuid>][action:do|info][reply:yes|no|end] <message>Messages not addressed to the configured routingAgent (or *) are silently ignored. Brackets inside the message body are preserved when the envelope header is stripped.
Terminal replies (reply=end): the bridge accepts and forwards reply=end. Terminal-thread enforcement (THREAD_CLOSED) and ref requirements are enforced by hermes-mesh: end marks a message as terminal and no reply is expected; hermes-mesh expects replies to a terminal message to carry ref=<anchor>.
Security Notes
- SSRF protection: outbound deliveries use OpenClaw's
fetchWithSsrFGuardwith per-peerallow_loopbackand the configurableallowLoopback/privateNetworkPolicysettings. By default private/loopback targets are rejected. SetallowLoopback: trueto allow loopback deliveries regardless of theprivateNetworkPolicydefault, or useprivateNetworkPolicy: "allow"/"warn"for more control. As a break-glass, setOPENCLAW_MESH_ALLOW_LOOPBACK=1. - Ed25519 signatures: outbound messages are signed with the sender's private key. Inbound messages are verified with the sender's cached public key from the mesh vault or the optional mesh-peer-registry.
- HMAC removed: the previous HMAC-SHA256 (
X-Hub-Signature-256) mode is no longer supported. Existing deployments must re-register agents to generate Ed25519 keys. - Certificate pinning: when using a registry over HTTPS, set
registryPinto the SHA-256 hex digest of the server certificate's SPKI, or setMESH_REGISTRY_PIN. - Envelope token validation:
from,to,id,action, andreplyfields are validated to keep the header well-formed. - Debug logging: gated by
config.debugorOPENCLAW_MESH_DEBUG; private keys and tokens are redacted from logs.
Development
npm run typecheck # TypeScript type check only
npm run build # Compile src/ → dist/
npm test # Run unit tests with node:testCI is configured in .github/workflows/ci.yml and runs typecheck, build, and test on every push and pull request to main.
Cross-harness mesh
The same [mesh] envelope and Ed25519 wire format works across three harnesses:
| Harness | Mesh bridge | |---|---| | Hermes | hermes-mesh | | OpenClaw | openclaw-mesh (this repo) | | diploid-agent | diploid-mesh | | Shared registry | mesh-peer-registry / PyPI |
All three share the same local vault layout (mesh/agents/<name>/identity.yaml) and the same optional mesh-peer-registry server. This means an OpenClaw agent can mesh_send to a Hermes peer, a Hermes agent can send to a diploid-agent peer, and an OpenClaw agent can receive a reply from a diploid agent — with the same identity files, the same envelope, and the same signatures everywhere.
License
MIT
