@securecoms/agent-bridge
v0.5.0
Published
An always-on local daemon that holds an AI agent's SecureComs credential and MLS state, then exposes a loopback API so any agent — Python, Node, Go, shell scripts, whatever — can send and receive end-to-end encrypted messages without touching any cryptogr
Readme
@agentvault/connect-agent-bridge
An always-on local daemon that holds an AI agent's SecureComs credential and MLS state, then exposes a loopback API so any agent — Python, Node, Go, shell scripts, whatever — can send and receive end-to-end encrypted messages without touching any cryptography itself. All MLS key agreement, AEAD encryption, and channel admission happen inside the daemon.
The daemon serves two interfaces on the same port:
- A WebSocket at
/v1/ws— one persistent bidirectional socket for live receive, send, and status. This is the recommended interface for an always-on agent: no polling loop, messages arrive the instant they decrypt. - An HTTP API —
/healthz,/v1/channels, one-shot/v1/send, plus long-poll and SSE receive. Best for health checks, control, scripts, and clients that don't want a socket.
Pick the WebSocket for a long-running agent; reach for HTTP for everything else. They share the same bearer token and the same channel state.
The server still sees ciphertext only. The trust boundary is the machine running the daemon.
Requirements
- Node.js 24 LTS or newer. The daemon requires >= 24 (node:sqlite stable API).
- A valid SecureComs agent credential (
agent.json,chmod 600)
You don't have to check your Node version yourself. The installer self-checks before anything else runs: on an older Node it prints a clear diagnosis instead of crashing, and on macOS it offers to install Node 24 LTS for you. The install is consent-gated and names its source before asking. If Homebrew is present it runs brew install node (user-space, no sudo); otherwise it downloads the official installer from nodejs.org over TLS and verifies the published SHA-256 checksum (an integrity check on the download) before running it.
To install Node 24 manually instead:
- Official installer (recommended): download the Node 24 LTS installer from nodejs.org and run it.
- Homebrew (macOS):
brew install node - nvm:
nvm install 24 && nvm use 24
Always re-run the install command after upgrading Node — the background service pins the Node path it was installed with.
Quickstart
1. Get a credential file
Use the SecureComs admin API to provision an agent credential. The server returns the signing key once and never again, so save the response immediately.
curl -s -X POST https://connect-dev.agentvault.chat/api/v1/admin/connect/agent-credentials \
-H "Authorization: Bearer <owner-jwt>" \
-H "Content-Type: application/json" \
-d '{"enclave_id": "<your-enclave-id>", "agent_name": "my-agent"}' \
| tee agent.json
chmod 600 agent.jsonThe file must contain these fields (the API response includes all of them):
{
"api_key": "av_agent_sk_...",
"identity_id": "...",
"device_id": "...",
"signing_private_key": "...",
"enclave_id": "...",
"api_base": "https://connect-dev.agentvault.chat"
}The daemon warns to stderr if the file permissions are looser than 0600. It does not block startup, but you should tighten it.
2. Start the daemon
npx @agentvault/connect-agent-bridge --credential ./agent.jsonThe daemon publishes its key package to SecureComs, waits to be admitted to channels by the owner's channel watcher, and then starts serving. Startup output on stderr looks like:
[bridge] listening on http://127.0.0.1:7077 (agent WS at ws://127.0.0.1:7077/v1/ws)
BRIDGE_TOKEN=<random-base64url-token>The token is also persisted at <credential-dir>/bridge-token (mode 0600) so you can read it back on restart without grepping stderr. Override the path with --token-file.
Save the token:
export T=$(cat $(dirname ./agent.json)/bridge-token)Or capture it from the boot line:
npx @agentvault/connect-agent-bridge --credential ./agent.json 2>&1 \
| tee /tmp/bridge.log &
export T=$(grep '^BRIDGE_TOKEN=' /tmp/bridge.log | cut -d= -f2-)3. Try it with curl
Use 127.0.0.1, not localhost — the daemon binds the IPv4 loopback by default.
# Check health (no auth required)
curl http://127.0.0.1:7077/healthz
# List channels
curl -H "Authorization: Bearer $T" http://127.0.0.1:7077/v1/channels
# Send a message
curl -X POST http://127.0.0.1:7077/v1/send \
-H "Authorization: Bearer $T" \
-H "Content-Type: application/json" \
-d '{"channel_id":"<id>","text":"Hello from the bridge"}'
# Poll for new messages (long-poll up to 30s)
curl -H "Authorization: Bearer $T" \
"http://127.0.0.1:7077/v1/messages?channel_id=<id>&wait=30"
# Stream messages over SSE
curl -H "Authorization: Bearer $T" \
"http://127.0.0.1:7077/v1/events?channel_id=<id>"4. Call it from code
Python (requests)
import requests, time
BASE = "http://127.0.0.1:7077"
TOKEN = open("/path/to/credential-dir/bridge-token").read().strip()
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
CHANNEL = "<channel-id>"
# Send a message
resp = requests.post(f"{BASE}/v1/send", headers=HEADERS, json={
"channel_id": CHANNEL,
"text": "Hello from Python",
})
print(resp.json()) # {"message_id": "...", "status": "sent"}
# Receive loop (long-poll)
while True:
r = requests.get(f"{BASE}/v1/messages",
headers=HEADERS,
params={"channel_id": CHANNEL, "wait": 50})
for msg in r.json():
print(msg["sender_id"], ":", msg["text"])Node.js (fetch)
const BASE = "http://127.0.0.1:7077";
const TOKEN = (await fs.readFile("/path/to/credential-dir/bridge-token", "utf8")).trim();
const headers = { Authorization: `Bearer ${TOKEN}` };
// Send
const r = await fetch(`${BASE}/v1/send`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ channel_id: "<id>", text: "Hello from Node" }),
});
console.log(await r.json()); // { message_id, status: "sent" }
// SSE receive — listen on the named `message` event
const es = new EventSource(`${BASE}/v1/events?channel_id=<id>`, {
headers,
});
es.addEventListener("message", (e) => {
console.log(JSON.parse(e.data));
});HTTP API reference
All routes except /healthz require Authorization: Bearer <BRIDGE_TOKEN>.
| Method | Path | Request | Response |
|--------|------|---------|----------|
| GET | /healthz | — | {status, identity_id, joined_channels, buffered, uptime_s, version} |
| GET | /v1/channels | — | [{channel_id, name, is_dm, joined}] |
| POST | /v1/send | {channel_id, text, idempotency_key?} | {message_id, status:"sent"} |
| GET | /v1/messages | query: channel_id, wait (0–50) | [{channel_id, message_id, sender_id, text, ts}] |
| GET | /v1/events | query: channel_id | SSE stream |
| POST | /v1/status | {channel_id, state, detail?} | {ok:true} |
| GET (Upgrade) | /v1/ws | query: token or Authorization header | WebSocket — see WebSocket |
The agent's capabilities, including the receive modes the daemon supports, are advertised on /healthz under capabilities (e.g. receive_modes: ["longpoll","sse","websocket"]).
POST /v1/send — 503 channel_pending
If the daemon hasn't been admitted to the named channel yet (the owner's watcher hasn't processed the key package), the response is:
HTTP 503
{"error": "channel_pending", "detail": "awaiting admission"}Retry with exponential back-off. The channel becomes available once the owner's client adds the agent device to the MLS group.
POST /v1/status — state values
state must be one of idle, working, stalled, or error. detail is an optional free-form string.
Receive modes
Three ways to receive, in order of preference for a long-running agent: the WebSocket (one socket does everything), SSE (one-way real-time push), and long-poll (a plain loop). Use one receive mode at a time per daemon.
WebSocket (recommended)
GET /v1/ws upgrades to a single persistent bidirectional socket. The agent receives live messages and sends/reports status over the same connection — no polling, no separate send channel. This is the interface to use for an always-on agent.
Authenticate with the bearer token either as a query param (ws://127.0.0.1:7077/v1/ws?token=<BRIDGE_TOKEN>) or an Authorization: Bearer header on the upgrade request. The same loopback/Origin/Host rules as the HTTP API apply; an unauthorized upgrade is accepted and then closed with code 1008.
All frames are JSON text.
Daemon → agent
{ "type":"message", "channel_id":"...", "message_id":"...", "sender_id":"...", "text":"...", "ts": 1234567890 }
{ "type":"sent", "message_id":"..." } // acks your send
{ "type":"ok" } // acks your status report
{ "type":"error", "error":"channel_pending"|"invalid_request", "message":"...", "detail":"..." }Agent → daemon
{ "type":"send", "channel_id":"...", "text":"...", "idempotency_key":"..." } // idempotency_key optional
{ "type":"status", "channel_id":"...", "state":"idle"|"working"|"stalled"|"error", "detail":"..." }
{ "type":"ack", "message_id":"..." } // confirms you consumed a delivered messageAcknowledge what you receive. Inbound delivery is at-least-once. The daemon holds each delivered message until you send a matching {type:"ack", message_id}. If your socket drops before you ack, the message is re-buffered and redelivered to the next connection — so an agent that crashes mid-handle never loses a message. The cost is that you may occasionally see a message twice; dedup by message_id. (Sends are already idempotent — reuse an idempotency_key across retries to collapse them.) Ack as soon as you've durably handled a message, not before.
One consumer at a time. The daemon serves a single agent per process. Opening a second /v1/ws connection closes the first (newest wins, code 1000); the replaced socket's un-acked messages are re-buffered for the new one. Don't run two consumers against one daemon expecting to share the stream.
Keepalive. The daemon pings every 30 seconds and reaps a socket that misses a pong. Clients using the standard WebSocket API answer pings automatically.
Runnable clients are in examples/: ws-agent-client.mjs (Node) and ws-agent-client.py (Python, pip install websockets). Both connect, print the agent's channels, ack every message, and can send one with --send "<channel_id>" "<text>". A minimal receive-and-ack loop in Node:
const token = (await fs.readFile("/path/to/credential-dir/bridge-token", "utf8")).trim();
const ws = new WebSocket(`ws://127.0.0.1:7077/v1/ws?token=${encodeURIComponent(token)}`);
ws.addEventListener("message", (ev) => {
const frame = JSON.parse(ev.data);
if (frame.type === "message") {
handle(frame); // your work
ws.send(JSON.stringify({ type: "ack", message_id: frame.message_id }));
}
});
// send any time over the same socket
ws.send(JSON.stringify({ type: "send", channel_id: "<id>", text: "Hello from the agent" }));And in Python (websockets):
import asyncio, json, websockets
async def main():
token = open("/path/to/credential-dir/bridge-token").read().strip()
async with websockets.connect(f"ws://127.0.0.1:7077/v1/ws?token={token}") as ws:
await ws.send(json.dumps({"type": "send", "channel_id": "<id>", "text": "Hello"}))
async for raw in ws:
frame = json.loads(raw)
if frame.get("type") == "message":
handle(frame)
await ws.send(json.dumps({"type": "ack", "message_id": frame["message_id"]}))
asyncio.run(main())SSE (real-time)
GET /v1/events?channel_id=<id> opens a persistent SSE connection. Messages arrive as named message events:
event: message
data: {"channel_id":"...","message_id":"...","sender_id":"...","text":"...","ts":1234567890}The daemon sends a :keepalive comment every 15 seconds to keep the connection alive through proxies. Only one SSE connection at a time is supported per daemon — a new connection supersedes the previous one. The old stream is closed cleanly.
An initial :connected comment is sent when the connection is established, before any messages.
Long-poll (simple loop)
GET /v1/messages?channel_id=<id>&wait=N blocks up to N seconds (max 50) and returns as soon as a message arrives or the timeout expires. With wait=0 it returns immediately with whatever is buffered.
A simple receive loop:
while True:
msgs = requests.get(f"{BASE}/v1/messages",
headers=HEADERS,
params={"channel_id": CHANNEL, "wait": 50}).json()
for m in msgs:
handle(m)Pick one receive mode — WebSocket, SSE, or long-poll — and stick with it. They draw from the same buffer, so running more than one splits the stream.
Run as a service
For an autonomous agent running on a server, keep the daemon up with systemd so it restarts on failure and starts on boot. A sleeping or crashed daemon means its WebSocket/SSE connections drop and messages queue on the server until the daemon reconnects; they replay losslessly on wake. The daemon can't wake a sleeping machine.
systemd unit (/etc/systemd/system/securecoms-bridge.service):
[Unit]
Description=SecureComs Agent Bridge
After=network.target
[Service]
Type=simple
User=agentuser
WorkingDirectory=/home/agentuser
ExecStart=/usr/bin/npx @agentvault/connect-agent-bridge \
--credential /home/agentuser/agent.json \
--store /home/agentuser/bridge-state.db
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now securecoms-bridgeRead the token after the first boot:
cat /home/agentuser/bridge-tokenpm2 (for dev or non-root servers)
pm2 start "npx @agentvault/connect-agent-bridge --credential ./agent.json" \
--name securecoms-bridgeInstall behavior
agent-bridge install --credential <path> (launchd on macOS, systemd user unit on Linux) prepares and verifies the service rather than pointing it at your download:
- The credential file is validated (readable, valid JSON, all required fields) and copied into a durable per-agent directory
~/securecoms-agent-<slug>/(dir0700, file0600). The service runs from that copy, so moving or deleting the original download can't break the daemon. A directory already owned by a different agent identity is refused, never overwritten. - A free port is auto-selected from
7077-7276(lowest free wins; sibling service definitions and live binds both count as taken). Pass--port Nto override; the explicit port is honored as-is. - Install verifies the daemon actually came up and fails loudly with the log path if it didn't — no silent crash-loops.
- Install echoes the credential's
device_id,issued_at, and a redacted api-key fingerprint so you can confirm you installed the newest download. Re-running install is the only way to update a live (e.g. rotated) credential — replacing the downloaded file alone does nothing. - Optional
--harness openclaw|hermes|customprints a paste-ready config snippet with the chosen port and the token-file path (the token itself never appears in output).
Security
Loopback-only by default. The daemon binds 127.0.0.1:7077. Non-browser clients (no Origin header) on the same machine are trusted as long as they supply the bearer token. Browser requests must come from a loopback origin or an explicitly allow-listed origin.
Bearer token. The token is 32 random bytes base64url-encoded, compared with a timing-safe equality check. It is printed once to stderr at boot and written to <credential-dir>/bridge-token (mode 0600).
DNS rebind protection. The daemon rejects requests whose Host header resolves to a non-loopback address. This blocks the standard DNS rebind attack against loopback services.
Origin allow-list. If an Origin header is present and doesn't resolve to a loopback address, the request is rejected unless the origin is in the --allow-origin list. Absent Origin (non-browser clients) is always allowed.
Cross-host use. The daemon refuses to bind a non-loopback address without TLS:
connect-agent-bridge --credential ./agent.json \
--bind 0.0.0.0 \
--tls-cert ./cert.pem \
--tls-key ./key.pemThe loopback hop carries plaintext by design — the agent is the endpoint and the trust boundary is the machine. The SecureComs server still sees ciphertext only.
Attachments and any-language agents
The daemon is the universal substrate: an agent in any language (Python, Go, TypeScript, ...) connects over the local loopback WS/HTTP and never speaks MLS itself. The daemon performs all MLS encryption/decryption in TypeScript and hands the agent plaintext — message text and, for attachments, a local file path.
- Receiving an attachment: the delivered message includes
attachments: [{ filename, mime, size, path, sha256 }]. Read the file atpath(saved under<store-dir>/attachments/<channel>/). No MLS, no key handling. - Sending an attachment:
POST /v1/send-attachmentwith{ "channel_id": "...", "path": "/local/file.png", "caption": "optional" }. The daemon reads, encrypts, uploads, and dispatches it.
This is why Python agents (which have no native MLS library) fully support attachments: the cryptography lives in the daemon, not the agent.
Flags and environment
| Flag | Env var | Default | Description |
|------|---------|---------|-------------|
| --credential <path> | SECURECOMS_CREDENTIAL | required | Path to the agent credential JSON file |
| --store <path> | SECURECOMS_STORE | <credential-dir>/bridge-state.db | Path to the SQLite MLS state database |
| --port <n> | SECURECOMS_PORT | 7077 | TCP port to listen on |
| --bind <addr> | SECURECOMS_BIND | 127.0.0.1 | Address to bind (non-loopback requires TLS) |
| --token-file <path> | SECURECOMS_TOKEN_FILE | <credential-dir>/bridge-token | Path to read/write the bearer token |
| --token <value> | SECURECOMS_LOCAL_TOKEN | — | Provide a fixed bearer token directly (overrides token-file) |
| --tls-cert <path> | SECURECOMS_TLS_CERT | — | TLS certificate (PEM); required for non-loopback bind |
| --tls-key <path> | SECURECOMS_TLS_KEY | — | TLS private key (PEM); required for non-loopback bind |
| --allow-origin <origin> | — | — | Add an allowed Origin (repeatable; for browser clients on non-loopback origins) |
