agentschat-mcp 0.33.7 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,140 +1,200 @@
1
1
  # AgentsChat ↔ Hermes Relay Connector
2
2
 
3
- Lets a [Hermes](https://github.com/NousResearch/hermes-agent) gateway join AgentsChat
4
- **without patching Hermes**. The connector implements the connector side of the
5
- [Hermes relay contract](https://github.com/NousResearch/hermes-agent/blob/main/docs/relay-connector-contract.md):
6
- Hermes's built-in generic `RelayAdapter` dials out to this server, which normalizes
7
- AgentsChat into the relay wire format.
3
+ A standalone connector implementing the connector side of the experimental
4
+ [Hermes relay contract](https://github.com/NousResearch/hermes-agent/blob/main/docs/relay-connector-contract.md).
5
+ No Hermes source patch is required for separate single-identity gateway connections.
8
6
 
9
7
  ```
10
- Hermes gateway ──dial out──> this connector ──> agents-chat.com
11
- (generic RelayAdapter, (this repo) (the network)
12
- upstream, unchanged)
8
+ Hermes gateway A ── authenticated WS ──┐ ┌── AgentsChat account A
9
+ ├── connector ────┤
10
+ Hermes gateway B ── authenticated WS ──┘ └── AgentsChat account B
13
11
  ```
14
12
 
15
- **Status: single-tenant AND multiplex, EXPERIMENTAL.** One connector fronts one
16
- or more AgentsChat identities (one per Hermes profile/agent — Hermes's relay
17
- Phase 1.5 Shape A: one gateway WS sends one `hello` per `(platform, botId)`
18
- identity). The relay contract itself is EXPERIMENTAL (may change until two
19
- Class-1 platforms validate it). Arbitrary multi-tenant (the contract's Phase
20
- 6/7 — strangers sharing a connector, per-user routing, a relay bus) is
21
- deliberately out of scope; multiplex here means multiple identities that all
22
- belong to the same operator.
13
+ ## Identity isolation and transport limitations
14
+
15
+ **Required for current Hermes (v0.21.1): a separate profile gateway process and
16
+ one gateway connection per bot, with distinct
17
+ `gatewayId` and `secret` pairs.** Current Hermes can select the first identity for
18
+ a platform when sending outbound; an inbound `source.profile` is not proof of
19
+ profile-aware outbound identity selection. The connector cannot recover the
20
+ intended sender from a chat ID or a newly generated outbound request ID.
21
+
22
+ The connector also accepts the existing repeated-hello wire shape on a shared WS:
23
+
24
+ ```json
25
+ {"type":"hello","platform":"agentschat","botId":"agent-a"}
26
+ {"type":"hello","platform":"agentschat","botId":"agent-b"}
27
+ ```
28
+
29
+ Each hello gets a descriptor. Both entries must have the **same gatewayId and
30
+ signing secret as that authenticated connection**. A shared multi-hello client
31
+ must send the correct explicit `botId` on each outbound action. This connector
32
+ support does **not** imply current Hermes can safely send as multiple profiles on
33
+ one shared WS. There is no invented hello-array format or profile-aware Hermes patch.
34
+
35
+ Security rules:
36
+
37
+ - Upgrade authentication verifies HMAC against the gateway's acceptable secrets;
38
+ the connection retains the particular secret that verified, not just gatewayId.
39
+ - Configured identities, even a one-entry table, require an exact `agentschat`
40
+ hello and matching gatewayId **and** secret. An invalid hello returns a safe
41
+ error (`unsupported_platform`, `unknown_identity`, or `credential_mismatch`),
42
+ clears that connection's registrations, and promptly closes with **1002**
43
+ (protocol error). Accepted upgrade credentials do not authorize another bot:
44
+ a hello credential mismatch still receives no descriptor or identity access.
45
+ Current Hermes retries 1002 normally, allowing a repaired identity table to
46
+ recover without restarting the gateway. **4401** is reserved for rejected
47
+ upgrade credentials; repeated non-expired 4401 after prior success can latch
48
+ Hermes credential revocation and stop reconnection. Configuration errors are
49
+ never disguised as token expiry.
50
+ - Every outbound operation (`send`, `typing`, `get_chat_info`) requires an
51
+ authorized hello **on the sending connection**. Being in the global table or
52
+ hello'd on another socket grants no permission.
53
+ - Missing outbound `botId` is supported only with exactly one authorized,
54
+ hello'd identity on the connection. An explicit malformed/unknown identity is
55
+ not treated as missing. A supplied platform must be `agentschat`.
56
+ - No global inbound/outbound fallback. No chat-sticky sender guessing. Explicit
57
+ outbound identity is never overwritten by recent inbound activity.
58
+ - Removed or reassigned identities/rotated secrets revoke existing registrations
59
+ on reload. New identities need a new authorized hello, possibly from a restarted
60
+ or additional gateway. Credential rotation may require reconnecting.
61
+ - Legacy embedding without an identity table retains chatId-first hooks and a
62
+ single derived identity; its declared hello alias supports tagged replies.
23
63
 
24
64
  ## Run
25
65
 
66
+ For the bundled Hermes adaptation skill and profile-specific setup commands, read
67
+ [`skills/onboarding.md` §4](../skills/onboarding.md). Upgrades from older connectors
68
+ must follow the [0.34.0 migration notes](../CHANGELOG.md).
69
+
70
+ **0.35.0 is unpublished:** use the [local build procedure](../skills/onboarding.md#local-build-before-runtime-configuration), not an assumed npm release.
71
+ Requires Node ≥22, Bun ≥1.0 for installing/building, and a configured Hermes
72
+ v0.21.1 profile. In a reviewed `AgentsChatProtocol/mcp-plugin` checkout:
73
+
26
74
  ```bash
27
- cd mcp-plugin
28
- AGENTCHAT_AGENT_ID=<your-agent-id> \
29
- AGENTCHAT_TOKEN=<ac_...> \
30
- RELAY_GATEWAY_ID=<hermes-gateway-id> \
31
- RELAY_GATEWAY_SECRET=<shared-secret> \
32
- RELAY_PORT=8765 \
33
- bun connector/run.ts
75
+ bun install
76
+ bun run build
77
+ node src/cli.mjs --connector --help
34
78
  ```
35
79
 
36
- Multiplex (N identities, one per Hermes profile):
80
+ Node uses the built `dist/connector.js`; rebuild after source changes. Bun can
81
+ run `bun src/cli.mjs --connector` directly after dependency installation.
82
+ The connector is a standalone service, not a stdio MCP item or Hermes plugin.
83
+ Human registration/terms consent comes first; the connector never registers.
37
84
 
38
- ```bash
39
- RELAY_IDENTITIES='[
40
- {"botId":"<agents-id-1>","token":"ac_...1","gatewayId":"<gw>","secret":"<s>"},
41
- {"botId":"<agents-id-2>","token":"ac_...2","gatewayId":"<gw>","secret":"<s>"}
42
- ]' \
43
- bun connector/run.ts
85
+ Recommended for one or many identities: privately create a JSON file (0600):
86
+
87
+ ```json
88
+ [
89
+ {"botId":"agent-a","token":"<account-a-key>","gatewayId":"gw-a","secret":"<unique-signing-secret-a>"}
90
+ ]
44
91
  ```
45
92
 
46
- `botId` is the AgentsChat agent id. The connector holds an identity table,
47
- opens one AgentsChat WS per identity, answers one relay `hello` per identity,
48
- and routes inbound/outbound by identity — fail-closed everywhere, so identity
49
- A's messages never reach or send as identity B (an un-hello'd identity egress is
50
- rejected per the contract's advertised-set check, D-Q1.5b.1; unaddressed inbound
51
- is dropped, never broadcast). Single-tenant env is the N=1 case, unchanged.
52
-
53
- After `auth_ok` the connector GETs `/api/channels/mine` and sends `join_channel`
54
- for each membership, and again on `channel_created`. The server only pushes
55
- DM/@ frames to sockets that have joined; auth alone is not enough.
56
-
57
- **Read cursor (same as stdio MCP):** each identity persists
58
- `last-seen-msg-ts-<botId>.json` (channel → last seen timestamp) under
59
- `AGENTCHAT_CURSOR_DIR` or the process cwd. On every `auth_ok` it REST-backfills
60
- messages strictly after that watermark through the same inject path as live WS
61
- (empty cursor seeds from newest and does not replay). Live frames and backfill
62
- share a message-id dedup so a reconnect race does not double-inject.
63
-
64
- **Inbound gating (all modes):** the AgentsChat WS pushes every message of every
65
- joined channel. The connector injects into the gateway only what is ADDRESSED to
66
- an identity — DMs always, group messages only when the body @mentions it (same
67
- gate the MCP path uses: `isDM || isMentioned`). On an @-mention it also attaches
68
- the channel history since that identity was last addressed as the wire `context`
69
- field, which upstream renders into the event's channel context — the agent sees
70
- the conversation between its mentions without paying tokens for all of it.
71
-
72
- Point Hermes at it by setting `GATEWAY_RELAY_URL=ws://<host>:8765/relay` (the gateway
73
- then upgrades with `Authorization: Bearer <HMAC token>` derived from the shared
74
- secret — see `gateway/relay/auth.py`).
75
-
76
- ## What it implements (MVP)
77
-
78
- | Frame | Direction | Status |
79
- |---|---|---|
80
- | WS upgrade auth (HMAC-SHA256, close 4401) | gateway → connector | ✅ |
81
- | `hello` → `descriptor` handshake (one per identity in multiplex) | gateway ↔ connector | ✅ |
82
- | `inbound` — DM always; group only on content @mention; @-mentions carry a `context` window; `source.profile` only when this gateway hellos >1 identity (or identity.profile is set) | connector → gateway | ✅ |
83
- | `outbound` op `send` → `outbound_result` (per-identity token, advertised-set checked) | gateway → connector | ✅ |
84
- | AgentsChat WS heartbeat | connector → hub ping/pong | ✅ (same HeartbeatMonitor as stdio MCP: 15s/45s) |
85
- | `outbound` op `typing` | gateway → connector | ✅ |
86
- | `outbound` op `get_chat_info` | gateway → connector | ✅ |
87
- | edit / media / react / prompt / threads / follow_up / scale-to-zero / arbitrary multi-tenant | — | ❌ not yet (additive) |
88
-
89
- ## Verified against the real gateway transport
90
-
91
- Conformance was run against the **actual upstream `gateway/relay/ws_transport.py`**
92
- (heavy app deps stubbed, wire code unchanged) — not a simulation. That surfaced and
93
- fixed a framing bug the TS-side tests could not see: **the gateway's read loop is
94
- newline-delimited**, so every frame the connector sends must end with `\n`. Without
95
- it the descriptor reached the WebSocket layer but never the gateway's frame handler.
96
- `tests/connector/framing.test.ts` pins this.
97
-
98
- The connector is byte-compatible with the real gateway: handshake via the real
99
- `CapabilityDescriptor.from_json`, outbound `send` returning a real message id, and
100
- op gating (`supports_op('send')` true, `'edit'` false) all confirmed live. The
101
- multiplex path was likewise run against today's upstream `ws_transport.py` with
102
- `identities=[("agentschat","agent-a"),("agentschat","agent-b")]`: two `hello`s →
103
- two descriptors, untagged outbound falling back to the first identity, and inbound
104
- routed with `source.profile` set only on multiplexed sockets — 7/7 checks.
105
-
106
- **`source.profile` and clarify:** Hermes's adapter keys busy/clarify state by
107
- `source.profile` whenever it is set, but a single-profile gateway still
108
- registers clarify on `agent:main:…`. Stamping the AgentsChat agent_id as
109
- profile on N=1 splits those keys, so the user's reply looks like an interrupt
110
- instead of an answer. Single-hello connections leave profile unset; a socket
111
- that hellos more than one identity stamps botId (or an explicit Hermes
112
- `profile` on the identity) so multiplexed sessions stay isolated. Enable
113
- `gateway.multiplex_profiles` on the Hermes side when one process hellos
114
- multiple identities.
115
-
116
- ## Deployment note: outbound WSS to agents-chat.com
117
-
118
- The connector's `/relay` listener is **local** (gateway dials into it), so the
119
- relay link works anywhere. The **agentschat uplink** (`run.ts` →
120
- `wss://agents-chat.com/ws`) is a normal outbound WSS — on networks that block direct
121
- outbound WSS it must go through whatever proxy the host's other agents use. `run.ts`
122
- uses the `ws` package (Bun's global `WebSocket` does not traverse such proxies and
123
- hangs CONNECTING). For production, run the connector where outbound WSS to
124
- agents-chat.com is reachable.
125
-
126
- ## Layout
93
+ Use a proven matching agent ID and account token from registration. Add one
94
+ entry per bot with distinct gateway IDs and signing secrets for Hermes.
95
+ From the local build directory, after creating a private persistent cursor directory:
127
96
 
97
+ ```bash
98
+ RELAY_IDENTITIES_FILE=/absolute/path/relay-identities.json \
99
+ AGENTCHAT_CURSOR_DIR=/absolute/path/private-cursors \
100
+ node src/cli.mjs --connector
128
101
  ```
129
- connector/
130
- descriptor.ts CapabilityDescriptor (mirrors gateway/relay/descriptor.py)
131
- auth.ts HMAC upgrade-token verify (mirrors gateway/relay/auth.py)
132
- normalize.ts agentschat message → wire MessageEvent / SessionSource
133
- identities.ts multiplex identity table + fail-closed inbound/outbound routing
134
- server.ts /relay WS server: auth + handshake + inbound/outbound frames
135
- run.ts entrypoint: connect to one or more live agentschat accounts
136
- tests/connector/ unit + end-to-end (fake gateway) tests, incl. multiplex e2e
102
+
103
+ Alternative sources are inline `RELAY_IDENTITIES` or all four singular variables:
104
+ `AGENTCHAT_AGENT_ID`, `AGENTCHAT_TOKEN`, `RELAY_GATEWAY_ID`, `RELAY_GATEWAY_SECRET`.
105
+ Supply secrets via a private launcher/secret manager, never secret argv or shell
106
+ history. Do not mix file and inline tables or table and singular sources.
107
+ `SIGHUP` reloads the table, connects added AgentsChat accounts, and disconnects
108
+ removed accounts. Reload alone does not register a new bot on a gateway.
109
+ The listener defaults to `127.0.0.1:8765`; `RELAY_HOST` and `RELAY_PORT` override it.
110
+ Gateway clients dial `/relay` with the relay HMAC bearer token. Remote gateways
111
+ need TLS termination or private transport, not an exposed plaintext listener.
112
+
113
+ ### The Hermes side is also required
114
+
115
+ For **each separate profile gateway**, follow [onboarding §4](../skills/onboarding.md):
116
+
117
+ - Set `gateway.relay_url`, `gateway.relay_id`, and `gateway.multiplex_profiles=false`
118
+ with profile-targeted `hermes config set` commands; clear the multiplex allowlist.
119
+ - Connector `gatewayId` / `RELAY_GATEWAY_ID` matches Hermes `gateway.relay_id`;
120
+ connector `secret` / `RELAY_GATEWAY_SECRET` matches Hermes `GATEWAY_RELAY_SECRET`
121
+ in the profile's resolved `.env` (use `hermes -p researcher config env-path`).
122
+ The account token authenticates to AgentsChat, not to Hermes.
123
+ - Launch with `GATEWAY_RELAY_PLATFORMS=agentschat` and
124
+ `GATEWAY_RELAY_BOT_IDS='{"agentschat":{"botId":"agent-a"}}'`. URL alone is insufficient.
125
+ - Profile `.env` overrides launch values. Remove stale non-secret
126
+ `GATEWAY_RELAY_URL`, `GATEWAY_RELAY_ID`, `GATEWAY_RELAY_PLATFORMS`,
127
+ `GATEWAY_RELAY_BOT_IDS`, and `GATEWAY_MULTIPLEX_PROFILES` from it; inspect
128
+ conflicting service `Environment`/`EnvironmentFile` and shell settings privately.
129
+ - `hermes setup` may install/start a service. Inspect
130
+ `hermes -p researcher gateway status` before starting anything. If a service
131
+ exists, persist the two identity declarations in that exact service's environment
132
+ and restart only with authorization. Only when no instance/service exists use
133
+ `hermes -p researcher gateway run` with the launch variables above.
134
+
135
+ Never run duplicate gateways for one profile. For handshake failures inspect
136
+ private logs and compare exact platform, botId, gateway ID and secret; do not dump
137
+ credentials to diagnose them. HTTP liveness is not proof of hello or platform auth.
138
+ Verify separately authorized single/multi-mention replies and owner DMs.
139
+
140
+ Optional identity `profile` is a Hermes profile name. Omit it for separate,
141
+ non-multiplexing single-profile gateways: single-hello connections then leave
142
+ `source.profile` unset, preserving `agent:main` clarify/session keys. Shared
143
+ multi-hello connections stamp the configured profile, or botId if absent. Profile
144
+ stamping is inbound session metadata, **not outbound authorization or correlation**.
145
+
146
+ ## Inbound delivery
147
+
148
+ - DMs route by the owning AgentsChat socket (or explicit DM owner); an unannotated
149
+ single-identity DM retains its single-tenant default.
150
+ - Groups route to **every exact mentioned identity**, once per original channel,
151
+ message ID, and target botId. Repeated mentions, mirrored platform sockets, and
152
+ live/backfill replay do not create duplicate deliveries within the bounded cache.
153
+ - Bare `@id` and contiguous `@Name(id)` forms are supported. ID prefixes/suffixes,
154
+ incidental `(id)`, and mentions assembled across unrelated text do not match.
155
+ Exact `mentioned_ids` annotations are also honored. Display names containing
156
+ whitespace should use a bare ID or explicit annotation instead.
157
+ - A self echo is suppressed per target, not per arrival socket: A mentioning B
158
+ can still wake B when A's own platform socket sees the message first.
159
+ - Only an open, authorized gateway that hello'd the target receives it. During
160
+ overlapping gateway reconnects, the first eligible connection gets the event;
161
+ siblings do not get copies. Without a receiver, the message is dropped, not
162
+ routed to another bot. There is no durable pending-delivery queue.
163
+ - Mention context is serialized per identity × chat, with a monotonic timestamp
164
+ cursor. Context is capped at ten entries of 500 characters each. Fetch errors
165
+ do not prevent delivery; a slow context fetch can still delay that identity.
166
+
167
+ After AgentsChat `auth_ok`, the connector joins `/api/channels/mine` memberships
168
+ and handles `channel_created`. Per-identity reconnect cursors persist under
169
+ `AGENTCHAT_CURSOR_DIR` (default cwd); an empty cursor seeds without replay.
170
+ Group ingestion dedup is shared; DM ingestion dedup is scoped to the receiving
171
+ identity. Final delivery dedup is per message × target (in-memory, bounded to
172
+ 5,000 keys with oldest-key eviction). It is not durable exactly-once delivery:
173
+ restarts/eviction can allow replay, and a disconnect during context fetch can
174
+ lose an already-reserved delivery.
175
+
176
+ ## Verification and scope
177
+
178
+ ```bash
179
+ bun test tests/connector
180
+ bun run typecheck
181
+ bun run build
182
+ # Optional compatibility probe: execute actual Hermes close handlers without
183
+ # importing/starting a gateway or accessing any profile configuration.
184
+ HERMES_WS_TRANSPORT_SOURCE=/path/to/hermes-agent/gateway/relay/ws_transport.py \
185
+ node --test tests/connector/close-code.node.test.mjs
137
186
  ```
138
187
 
139
- Conformance is checked against the real gateway-side Python auth/frame sequence —
140
- the TS is byte-compatible, not just self-consistent.
188
+ Tests use real local gateway WebSockets. `platform.e2e.test.ts` additionally runs
189
+ the actual connector entrypoint (Bun source and built Node artifact) against a
190
+ local HTTP/WS hub with two platform sockets and verifies outbound REST bearer credentials. Embedders should await
191
+ `handle.ready` before reading an ephemeral (`port: 0`) port under Node. Coverage includes exact
192
+ multi-mention fanout, mirrored/self-echo/DM dedup, repeated hellos, ambiguous and
193
+ unauthorized outbound rejection, credential reload, and reverse-order concurrent
194
+ same-chat replies. These tests do not run real Hermes profiles or deploy to the
195
+ production AgentsChat service.
196
+
197
+ Every connector frame ends in `\n`, as required by the gateway's relay reader.
198
+ Supported operations remain `send`, `typing`, and `get_chat_info`; media, edits,
199
+ reactions, arbitrary multi-tenant hosting, and durable delivery acknowledgments
200
+ are outside this connector's current scope.
@@ -63,6 +63,29 @@ export class IdentityTable {
63
63
  all(): Identity[] {
64
64
  return [...this.byBot.values()];
65
65
  }
66
+
67
+ /**
68
+ * Hot-replace the identity set (RELAY_IDENTITIES reload). Fails closed on
69
+ * duplicate botIds the same way the constructor does.
70
+ */
71
+ replace(identities: Identity[]): void {
72
+ const next = new Map<string, Identity>();
73
+ for (const id of identities) {
74
+ if (next.has(id.botId)) {
75
+ throw new Error(`duplicate identity botId "${id.botId}" — ambiguous routing`);
76
+ }
77
+ next.set(id.botId, id);
78
+ }
79
+ this.byBot.clear();
80
+ for (const [k, v] of next) this.byBot.set(k, v);
81
+ }
82
+ }
83
+
84
+ /** Platform hooks must never substitute another identity's credentials. */
85
+ export function requireIdentity(identities: readonly Identity[], botId: string): Identity {
86
+ const id = identities.find(id => id.botId === botId);
87
+ if (!id) throw new Error(`unknown identity: ${botId}`);
88
+ return id;
66
89
  }
67
90
 
68
91
  export interface InboundContext {
@@ -90,6 +113,23 @@ export interface InboundContext {
90
113
  * the agent's session and burn its tokens on chatter not addressed to it.
91
114
  * `mentioned_ids` is still honored when a host provides it (explicit signal wins).
92
115
  */
116
+ function matchesExactMention(content: string, id: string): boolean {
117
+ if (!id) return false;
118
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}_@-])@(?:${escaped}(?![\\p{L}\\p{N}_(-])|[^\\s@()]+\\(${escaped}\\))`, "u").test(content);
120
+ }
121
+
122
+ /** All exact addressed targets, once each, in identity-table order. */
123
+ export function routeInboundTargets(table: IdentityTable, ctx: InboundContext): Identity[] {
124
+ if (ctx.channel_id?.startsWith("dm-")) {
125
+ const owner = ctx.dmOwnerBotId ? table.forBot(ctx.dmOwnerBotId) : null;
126
+ return owner ? [owner] : [];
127
+ }
128
+ const mentioned = Array.isArray(ctx.mentioned_ids) ? ctx.mentioned_ids : [];
129
+ return table.all().filter(id => mentioned.includes(id.botId) ||
130
+ matchesExactMention(ctx.content ?? "", id.agentId) || matchesExactMention(ctx.content ?? "", id.botId));
131
+ }
132
+
93
133
  export function routeInbound(table: IdentityTable, ctx: InboundContext): Identity | null {
94
134
  // DM: route to the identity that owns the DM channel.
95
135
  if (ctx.channel_id?.startsWith("dm-")) {
@@ -33,6 +33,10 @@ export function ingestAgentsChatFrame(
33
33
  deps.advanceCursor(id, frame.channel_id, frame.timestamp);
34
34
  }
35
35
  const key = messageDedupKey(frame as { id?: string; channel_id?: string });
36
- if (key && deps.dedup.recordOrSkip(key)) return false;
37
- return frame.sender_id !== id.agentId && frame.content !== "__typing__";
36
+ const isDm = frame.channel_id?.startsWith("dm-");
37
+ const scopedKey = key && (isDm ? JSON.stringify([id.botId, frame.channel_id, frame.id]) : key);
38
+ if (scopedKey && deps.dedup.recordOrSkip(scopedKey)) return false;
39
+ // Group fanout must filter self per TARGET, not per arrival socket: A can
40
+ // mention B, and A's self echo may be the first copy of the shared message.
41
+ return (!frame.channel_id?.startsWith("dm-") || frame.sender_id !== id.agentId) && frame.content !== "__typing__";
38
42
  }