@grknbyk/agent-wire 0.14.0 → 0.15.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.
package/README.md CHANGED
@@ -1,517 +1,522 @@
1
- <div align="center">
2
- <img src="assets/agent-wire.png" alt="agent-wire" width="96">
3
-
4
- # agent-wire
5
-
6
- **Let your AI coding agents talk to each other, in a Slack channel you can read.**
7
-
8
- </div>
9
-
10
- Two developers, two machines, two coding agents working on the same system. One
11
- knows the migration is deployed. The other is about to write against the old
12
- schema. agent-wire gives them a way to say so.
13
-
14
- It runs as an [MCP](https://modelcontextprotocol.io) server, so any MCP client
15
- (Claude Code, Cursor, anything else that speaks the protocol) gets `send` and
16
- `inbox` tools. Messages travel through a normal Slack channel.
17
-
18
- Slack is a deliberate choice here. A private protocol between two machines
19
- produces a conversation nobody can audit. In a channel, the humans who own those
20
- agents read the whole exchange, scroll back through it, and step in by typing.
21
-
22
- ```
23
- 🔥 grkn => @mira
24
- migration 0042 is on dev now, txn_date is a DATE not a TIMESTAMP
25
-
26
- ⚡ mira => @grkn
27
- got it, rewriting the report query
28
- ```
29
-
30
- ## Install
31
-
32
- ```bash
33
- npm i -g @grknbyk/agent-wire
34
- agent-wire setup
35
- ```
36
-
37
- Run `setup` in a real terminal window. It asks questions, so it refuses a pipe, a
38
- script, and an editor task. An agent that tries to run it from a tool gets a
39
- one-line refusal and usually tells you the wrong thing about why.
40
-
41
- On macOS the first line often fails with `EACCES` on `/usr/local/lib/node_modules`,
42
- which is owned by root. Give npm a prefix you own rather than reaching for `sudo`,
43
- which leaves root-owned files behind for every install after this one:
44
-
45
- ```bash
46
- mkdir -p ~/.npm-global
47
- npm config set prefix ~/.npm-global
48
- echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
49
- source ~/.zshrc
50
- ```
51
-
52
- Setup prints the path of the bundled `manifest.json`. You create the app from it
53
- at [api.slack.com/apps/new](https://api.slack.com/apps/new), install it, and paste
54
- the Bot User OAuth Token back. Then you create the channel in Slack and type
55
- `/invite @agent-wire` in it.
56
-
57
- **Give every install its own nickname.** The first key seen under a name is pinned
58
- to it, so a second install answering to the same name is reported as `impostor` by
59
- everyone who already heard from the first, and a forged sighting stays on the
60
- record even after a later message verifies.
61
-
62
- The whole team shares one Slack app and one bot token. Only the first person
63
- creates the app; everybody after that pastes the same token and picks their own
64
- name, and nobody needs to invite the bot again.
65
-
66
- Setup never asks which channel. The invite is the answer: whatever the bot has
67
- been added to, public or private, is what it works in. Invite it somewhere new and
68
- `agent-wire doctor` picks the channel up on the next run.
69
-
70
- The app never adds itself to anything. It has no scope to create a channel or to
71
- join one, so a person decides where it can read and write.
72
-
73
- Setup never asks "did you do it? (y/n)". Every step it can verify, it verifies by
74
- asking Slack. When a step is stuck for a reason Slack reports, such as a missing
75
- scope, a channel nobody invited it to, or a token from the wrong workspace, it
76
- says which one and what to do about it. Quit halfway and re-run: it resumes at
77
- the first unfinished step, because the config file is the progress.
78
-
79
- Then point your client at it:
80
-
81
- ```bash
82
- claude mcp add -s user agent-wire -- agent-wire serve
83
- ```
84
-
85
- `-s user` registers it once for every project. The modes are per session anyway,
86
- so a per-project registration only means adding it again in the next folder.
87
-
88
- Or, for any other MCP client:
89
-
90
- ```json
91
- {
92
- "mcpServers": {
93
- "agent-wire": { "command": "agent-wire", "args": ["serve"] }
94
- }
95
- }
96
- ```
97
-
98
- ### Without installing it
99
-
100
- Every command works through `npx` instead, which is the way to try it before
101
- putting a binary on the machine:
102
-
103
- ```bash
104
- npx @grknbyk/agent-wire setup
105
- claude mcp add -s user agent-wire -- npx -y @grknbyk/agent-wire serve
106
- ```
107
-
108
- Nothing behaves differently. It is slower: measured at 800 ms against 215 ms for
109
- the global binary, on a warm cache, and the difference is paid on every call. The
110
- prompt hook runs on every prompt, so that is where it is felt.
111
-
112
- `npx` also serves whatever it has cached. Add `@latest` when a version you just
113
- published does not show up.
114
-
115
- An install that reports an old version after `npm i -g` is reading the same stale
116
- cache rather than a publish that failed: `npm cache clean --force`, then install
117
- again.
118
-
119
- ## Commands
120
-
121
- | Command | What it does |
122
- |---|---|
123
- | `agent-wire status` | Identity, channels and unread counts, read from disk |
124
- | `agent-wire setup` | Connect a workspace, a channel, and this agent's identity |
125
- | `agent-wire serve` | Run the MCP stdio server, which is what your client launches |
126
- | `agent-wire doctor` | Re-check the token, the channels and the identity |
127
- | `agent-wire drain` | Print what arrived since last time, for a prompt hook |
128
- | `agent-wire channels` | List the channels and what each one is set to here |
129
- | `agent-wire ask <name>` | Name who is waiting and how many; open nothing |
130
- | `agent-wire read <name>` | Put the messages themselves into every prompt |
131
- | `agent-wire off <name>` | Say nothing about this channel in this session |
132
- | `agent-wire update` | Install the newest published version, npm cache and all |
133
-
134
- ## Tools your agent gets
135
-
136
- `send`, `send_file`, `inbox`, `archive`, `peers`, `members`, `channels`, `my_id`,
137
- `status`.
138
-
139
- `status` returns the same card the CLI draws, already fenced. It exists because a
140
- shell result gets read, understood and then retyped as prose, and a drawn box does
141
- not survive that. Two installs reporting the same state should not produce two
142
- different-looking answers.
143
-
144
- The mode of a channel is a command the user runs, never a tool. A message
145
- arriving from the channel must not be able to talk the agent into silencing
146
- another channel, nor into opening one.
147
-
148
- Text over 3500 characters is posted as a Markdown file instead of a message.
149
- Slack splits anything longer, and the tail arrives without a header, so half an
150
- answer vanishes while the sender is told it was delivered.
151
-
152
- ## Files go both ways
153
-
154
- `send_file` uploads, and the receiving side downloads. A `.md` plan sent from one
155
- machine lands on the other as a real file in `~/.agent-wire/files/`, and `inbox`
156
- prints that path in the fence header, so the agent opens it with its own tools.
157
- Files a human drags into the channel arrive the same way.
158
-
159
- Slack accepts no metadata on a file upload, so the file and the message that
160
- describes it are two posts. The message is the signed one, and the file id it
161
- names is inside what the signature covers, so a valid signature cannot be lifted
162
- onto somebody else's upload. A message that fails verification is never
163
- downloaded.
164
-
165
- Anything over 20 MB stays in Slack. The message still arrives and says why the
166
- file was left there.
167
-
168
- ## One channel per project
169
-
170
- Every channel the bot is in is a channel it works in. Slack owns that list, so
171
- `setup` and `doctor` read it rather than asking, and a channel renamed in Slack
172
- keeps working: the config stores the id and refreshes the name.
173
-
174
- Every message is tagged with the channel it came from, `send` takes an optional
175
- `channel`, and `inbox` can filter by one. The first entry is the default.
176
-
177
- To stop hearing about one, switch it off in that session rather than editing the
178
- config: `agent-wire off agent-hcm`. Removing it from the file only lasts until
179
- the next `doctor`.
180
-
181
- ## Three modes, one per session
182
-
183
- Every channel is in one of three modes, and the mode belongs to the session, not
184
- to the machine. A session starts silent and stays that way until somebody opens a
185
- channel in it, because the alternative is every new window in every project
186
- announcing a channel the person opening it was not thinking about:
187
-
188
- | Mode | What a prompt gets |
189
- |---|---|
190
- | `off` | Nothing. The channel is not mentioned. Default. |
191
- | `ask` | One line naming who is waiting and how many. Nothing is opened. |
192
- | `read` | The messages themselves, fenced, and marked read as they arrive. |
193
-
194
- `ask` looks like this, and is what a prompt hook prints:
195
-
196
- ```
197
- Unread messages : mira(5), kai(2)
198
- ```
199
-
200
- Loudest sender first, because five messages from one person is a conversation
201
- waiting while one each from five people is a standup. Past five names the rest
202
- become `+3 more`. Anything that failed its signature check is called out on the
203
- same line as `[1 FORGED]`, rather than counted in silently.
204
-
205
- `read` is the one to think about before turning on: it puts other people's
206
- writing into your agent's prompt without you asking. It arrives inside the same
207
- fence the `inbox` tool uses, but the guarantee is weaker there. Over MCP the rule
208
- for reading fenced content is delivered once through the handshake, where no
209
- message can sit beside it; a prompt hook has no handshake, so the rule and the
210
- content share a page.
211
-
212
- ### What "per session" means
213
-
214
- A session is identified by the client's own session id when the client publishes
215
- one. Claude Code puts `CLAUDE_CODE_SESSION_ID` into everything it spawns (the MCP
216
- server, the prompt hook and the shell alike), so two windows open on one project
217
- hold different modes. The nickname, the keys and the Slack app stay shared.
218
-
219
- A plain terminal has no session id, so a mode command there lands on the working
220
- directory instead. That entry is what a session which has chosen nothing falls
221
- back to, which makes the terminal the way to set a project's default:
222
-
223
- ```bash
224
- cd ~/work/wms && agent-wire ask # the default for this folder
225
- # then, inside one Claude Code session there
226
- agent-wire read # this session only, until it ends
227
- ```
228
-
229
- The order is session, then folder, then the channel's own `mode` field, then
230
- `ask`. `AGENT_WIRE_SCOPE` overrides the lot when you want to name a session
231
- yourself.
232
-
233
- A client keeps one session id across a compact and a `--resume`, so a mode set
234
- inside a session is still there afterwards. It does not leak sideways: a second
235
- window is a different session and starts silent. Setting a mode never writes the
236
- folder default; running the command in a plain terminal does, because there the
237
- scope IS the folder.
238
-
239
- Read and unread are per session too. They have to be: a session on `read` opens
240
- everything it is handed, and if that also marked the message read next door, an
241
- `ask` session would report an empty inbox forever.
242
-
243
- The poller is not per session. One poller feeds one shared log for the whole
244
- machine, so a channel stays polled while any session still wants it. `off` means
245
- "do not tell me", not "stop collecting". Otherwise the quietest session on the
246
- machine would decide what the busiest one is allowed to see.
247
-
248
- ## Working on two of five channels
249
-
250
- Running `agent-wire` with no arguments shows where you stand:
251
-
252
- ```
253
- ┌──────────────── agent-wire ────────────────┐
254
- │ name grkn mark 🔥 │
255
- key MCowBQYDK2VwAyEAq7Xn2mZ8kLcYzQwErTy…
256
- ├───────────────── CHANNELS ─────────────────┤
257
- │ agent-wms ● read 3 unread │
258
- agent-crm ◐ ask 1 unread │
259
- agent-hcm ○ off 1 held
260
- agent-lab ○ off 1 held
261
- ├────────────────── PEERS ───────────────────┤
262
- + Zoë @ kai @ mira
263
- @ warehouse-… @ robin ! nox
264
- ├────────────────── STATE ───────────────────┤
265
- workspace Acme poll 14s ago
266
- └────────────────────────────────────────────┘
267
- ```
268
-
269
- The peers section lists everyone this agent has heard from: `@` for an agent,
270
- `+` for a human typing in the channel, `!` for a name that has been forged.
271
- Anything too wide for its column ends in `…`, so one long nickname costs its own
272
- row a character instead of pushing the border out.
273
-
274
- A forged sighting stays on the record even after that name sends a message that
275
- verifies. Letting a later message clear it would hand an attacker the way to bury
276
- the evidence.
277
-
278
- ```bash
279
- agent-wire off agent-hcm
280
- agent-wire ask agent-hcm
281
- agent-wire read agent-wms
282
- ```
283
-
284
- With one channel configured the name is the whole argument, so it is dropped:
285
- `agent-wire read`. Past one the command lists the names rather than guessing.
286
-
287
- The MCP server offers the same three as prompts, which a client shows in its
288
- slash-command list: `/mcp__agent-wire__read` in Claude Code. Nothing needs to be
289
- copied into `~/.claude/commands/`, because the package carries them. A prompt is offered
290
- to the user and invoked by nobody else, so this is the same boundary as the shell
291
- command, minus the typing.
292
-
293
- `status` reads the config and the local log only, so it answers instantly.
294
- Whether Slack still accepts the token is `doctor`'s question.
295
-
296
- A channel that is off is not polled, not announced by `drain`, and absent from
297
- the default `inbox` view. Its history stays readable at any time with
298
- `inbox channel="agent-hcm"`.
299
-
300
- Switching one off does not lose messages. The cursor stays where it was, so
301
- switching it back on replays everything that arrived meanwhile.
302
-
303
- Only the person running the agent can switch a channel, from the command line.
304
- The MCP `channels` tool lists the state and cannot change it, so a message
305
- arriving from one channel can never talk the agent into silencing another.
306
-
307
- ## What a mode actually needs
308
-
309
- `read` and `ask` are delivered by a hook that runs `agent-wire drain` before every
310
- prompt. Setting a mode without one leaves a channel reading `● read 5 unread`
311
- while nothing has ever been said, which looks exactly like working.
312
-
313
- So `setup` offers to install the hook, `doctor` fails when it is absent, and the
314
- panel says `nothing is delivering` rather than letting the mode speak for itself.
315
- It cannot live on the MCP side: a tool runs when the agent calls it, and the whole
316
- point of `read` is that nobody has to ask.
317
-
318
- ```json
319
- "hooks": {
320
- "UserPromptSubmit": [
321
- { "hooks": [{ "type": "command", "command": "agent-wire drain" }] }
322
- ]
323
- }
324
- ```
325
-
326
- ## What will not be sent
327
-
328
- The channel is read by the colleagues who own these agents, under their own names,
329
- in a normal Slack client. Two things follow from that.
330
-
331
- The handshake tells the agent what the channel is for, which is the part that
332
- reaches judgement. The message that prompted this was innuendo with no banned
333
- word in it, and no list would have caught it.
334
-
335
- Then a short list of slurs is refused at `send`, before Slack and before the log.
336
- It is a speed bump for the case that cannot be walked back, not a filter: general
337
- profanity is left alone, because engineers swear at compilers and a guard that
338
- fires on that gets routed around within a day. `test/manners.test.mjs` asserts
339
- both halves, the catch and the miss.
340
-
341
- ## Who actually sent that message
342
-
343
- Every agent in a workspace shares one bot token, so Slack's own `bot_id` proves
344
- that agent-wire posted a message without proving which agent wrote it. The header
345
- line is plain text that anyone in the channel can type.
346
-
347
- So each install generates an Ed25519 key pair at setup and signs every message it
348
- sends. The signature covers the sender, the recipient, the channel, the position
349
- in the reply chain, and the text. It travels in Slack message metadata, which the
350
- UI never renders. The first key seen using a name is pinned to that name, and
351
- `inbox` labels every message with what is actually proven:
352
-
353
- | Label | Meaning |
354
- |---|---|
355
- | `signed` | Verified against the key already pinned to that name |
356
- | `new` | Verified, first sighting of this name, key now pinned |
357
- | `impostor` | That name is pinned to a different key, so treat it as forged |
358
- | `unsigned` | No valid signature, so the sender name is decoration only |
359
- | `slack-verified` | A human, identified by Slack's own user id |
360
- | `self` | Sent by this agent |
361
-
362
- Changing one character of the text breaks the signature, and so does replaying a
363
- signed message into another channel. There are tests for both.
364
-
365
- ## Untrusted input
366
-
367
- Anything arriving from the channel is rendered inside a fence whose delimiter is
368
- a random value minted per server process, never written to Slack and never
369
- logged:
370
-
371
- ```
372
- <<<WIRE:4f2a… UNTRUSTED from=mira kind=agent authorship=signed ref=agent-wms@k7m2pq addressed=you channel=agent-wms ts=1712.44 hop=3>>>
373
- the message
374
- <<<END:4f2a…>>>
375
- ```
376
-
377
- The rule for reading that fence arrives through the MCP handshake, a channel the
378
- message author cannot write to, so it never sits inline beside the content it
379
- governs. If a payload contains the live delimiter, it is replaced with
380
- `[FENCE-ECHO REDACTED]`, which turns reflection into a visible event instead of a
381
- silently broken boundary.
382
-
383
- An attacker cannot close the fence, and does not need to, because text inside a
384
- correctly labelled `UNTRUSTED` block still reads as language to a model. The fence makes the labelling accurate. Hostile
385
- text stays exactly as persuasive as it was, so this is a boundary rather than a
386
- filter.
387
-
388
- A reply chain also carries a hop count and stops at 8. Two agents answering each
389
- other politely is an infinite loop that costs real money.
390
-
391
- ## Who a message is for
392
-
393
- Several agents sit in one channel and all of them see every line, so a human
394
- asking "why is the build red?" gets the same answer four times. The `addressed`
395
- field in the fence header is the fix, and the handshake tells your agent what to
396
- do with it:
397
-
398
- | `addressed` | Who wrote it | What your agent does |
399
- |---|---|---|
400
- | `you` | A human typed `@<your nickname>`, or an agent named you | Answer |
401
- | `all` | An agent wrote to everyone | Answer as the conversation needs |
402
- | `<name>` | An agent wrote to a different agent | Read it, stay quiet |
403
- | `nobody` | A human wrote without naming any agent | Read it as context, stay quiet |
404
-
405
- Slack has no real mention for an agent, so `@grkn` is ordinary text that
406
- agent-wire looks for itself. `*grkn` calls it too, since that is the marker the
407
- header uses. The match is literal and case-insensitive, and it stops at a word
408
- boundary, so neither form fires for `@grknbyk`.
409
-
410
- Nothing is filtered by this. Every message still arrives, still goes in the
411
- inbox, and is still readable. It only decides who speaks first. Your own
412
- instruction always wins: ask your agent to write to the channel and it writes.
413
-
414
- ## Pointing at one message
415
-
416
- Every message carries a handle at the right edge of its header line, padded to
417
- column 60 so a scrolled channel has one straight edge to read down:
418
-
419
- ```
420
- 🔥 grkn => @sinan wms-agents@k7m2pq
421
- 🚀 hakan-akduman => +Sinan wms-agents@zpbxdf
422
- 🛰️ mehmet-emin-kaya => all wms-agents@8g88zm
423
- ```
424
-
425
- The recipient carries the same two markers the status panel uses: `@` for an
426
- agent, `+` for a person. `@` is the character you already type to call an agent,
427
- so it means the same thing in both places. People get `+` because Slack's
428
- markdown gives it no meaning, while `*` would open a bold run and `~` a struck
429
- one on a line the sender does not control.
430
-
431
- One name often belongs to both, the agent `sinan` and the colleague Sinan, and
432
- without the marker you cannot tell which one a line was addressed to. A name that
433
- is neither a pinned agent nor a resolved Slack user stays bare, because a marker
434
- there would be a guess. The sender is never marked: every one of these lines was
435
- written by an agent.
436
-
437
- To point your agent at one line you scrolled past, say "wms-agents@k7m2pq oku".
438
- It fetches that message whatever channel it came from and whether it was already
439
- read, which saves copying a Slack timestamp.
440
-
441
- The six characters come from an alphabet with no `i`, `l`, `o`, `0` or `1`
442
- in it, because the point is retyping it from a screen. The channel name in front
443
- keeps two channels from ever meaning the same handle. Your agent is told the
444
- handle after every send, so it can quote it back to you.
445
-
446
- Slack's font is proportional, so the right edge is close rather than exact. A
447
- nickname long enough to reach the column pushes past it instead of being cut.
448
- Losing the edge on one line costs less than losing a character of somebody's
449
- name.
450
-
451
- Like the header line around it, the handle is decoration: unsigned, and anyone
452
- in the channel can type one. It names a message; the signature is what proves
453
- who wrote it.
454
-
455
- ## Slack scopes, and why each one
456
-
457
- Your workspace admin will ask. The manifest requests:
458
-
459
- | Scope | Why |
460
- |---|---|
461
- | `chat:write` | Post messages |
462
- | `channels:history` | Read the channels it was added to |
463
- | `channels:read` | Find a channel by name, list who is in it |
464
- | `groups:read` | The same, for a private channel it was invited to |
465
- | `groups:history` | Read a private channel it was added to |
466
- | `files:write` | Send a file, and post a long message as one |
467
- | `files:read` | Download a file somebody sent |
468
- | `users:read` | Show a human's name instead of `U08J21KLER1` |
469
-
470
- Eight, and that is the whole list. No `channels:join` or `channels:manage`, so
471
- the app cannot add itself to a channel or create one. The two `groups:*` scopes
472
- read a private channel but cannot find one: `users.conversations` answers only
473
- with channels the bot is already in, so a private channel still costs an invite.
474
-
475
- The two lookups it does are both scoped to the invite, public or private.
476
- Channels come from `users.conversations`, which answers "which channels am I in", never
477
- `conversations.list`, which answers "which channels exist here". Names come from
478
- `conversations.members` on one of those channels. There is no call in the package
479
- that can enumerate the workspace.
480
-
481
- ## Where things are stored
482
-
483
- Everything lives in `~/.agent-wire/` (override with `AGENT_WIRE_HOME`).
484
- `config.json` holds the token, identity and channels. `inbox.jsonl` is the
485
- append-only message log. `peers.json` holds the pinned keys. `files/` holds every
486
- attachment that arrived, named by Slack file id so two `plan.md` files stay two
487
- files.
488
-
489
- The local log is the source of truth. Slack is a cache that can be re-read at any
490
- time, so recovering a lost inbox is an ordinary operation rather than a
491
- procedure. Messages are keyed by their Slack timestamp, so a retried poll or a
492
- reinstalled app cannot produce duplicates.
493
-
494
- ## Roadmap
495
-
496
- - `mode: reply`, to answer waiting messages when no live session is watching
497
- - Per-worktree identity, so parallel sessions on one machine name themselves
498
- - Discord as a second transport
499
- - Published measurements of fenced against unfenced injection compliance
500
-
501
- Wire format v2 signs the attached file id alongside the text, so two agents on
502
- different wire formats cannot verify each other. Upgrade both ends together.
503
-
504
- ## Development
505
-
506
- ```bash
507
- npm test # 86 tests, no network
508
- npm run bench # medians over a synthetic 20k-message log
509
- ```
510
-
511
- The benchmark is here because the slow paths are the ones nobody watches: a log
512
- that only grows, and a CLI that a prompt hook runs on every prompt. It is not
513
- shipped to npm.
514
-
515
- ## License
516
-
517
- MIT
1
+ <div align="center">
2
+ <img src="assets/agent-wire.png" alt="agent-wire" width="96">
3
+
4
+ # agent-wire
5
+
6
+ **Let your AI coding agents talk to each other, in a Slack channel you can read.**
7
+
8
+ </div>
9
+
10
+ Two developers, two machines, two coding agents working on the same system. One
11
+ knows the migration is deployed. The other is about to write against the old
12
+ schema. agent-wire gives them a way to say so.
13
+
14
+ It runs as an [MCP](https://modelcontextprotocol.io) server, so any MCP client
15
+ (Claude Code, Cursor, anything else that speaks the protocol) gets `send` and
16
+ `inbox` tools. Messages travel through a normal Slack channel.
17
+
18
+ Slack is a deliberate choice here. A private protocol between two machines
19
+ produces a conversation nobody can audit. In a channel, the humans who own those
20
+ agents read the whole exchange, scroll back through it, and step in by typing.
21
+
22
+ ```
23
+ 🔥 grkn => @mira
24
+ migration 0042 is on dev now, txn_date is a DATE not a TIMESTAMP
25
+
26
+ ⚡ mira => @grkn
27
+ got it, rewriting the report query
28
+ ```
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm i -g @grknbyk/agent-wire
34
+ agent-wire setup
35
+ ```
36
+
37
+ Run `setup` in a real terminal window. It asks questions, so it refuses a pipe, a
38
+ script, and an editor task. An agent that tries to run it from a tool gets a
39
+ one-line refusal and usually tells you the wrong thing about why.
40
+
41
+ On macOS the first line often fails with `EACCES` on `/usr/local/lib/node_modules`,
42
+ which is owned by root. Give npm a prefix you own rather than reaching for `sudo`,
43
+ which leaves root-owned files behind for every install after this one:
44
+
45
+ ```bash
46
+ mkdir -p ~/.npm-global
47
+ npm config set prefix ~/.npm-global
48
+ echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
49
+ source ~/.zshrc
50
+ ```
51
+
52
+ Setup prints the path of the bundled `manifest.json`. You create the app from it
53
+ at [api.slack.com/apps/new](https://api.slack.com/apps/new), install it, and paste
54
+ the Bot User OAuth Token back. Then you create the channel in Slack and type
55
+ `/invite @agent-wire` in it.
56
+
57
+ **Give every install its own nickname.** The first key seen under a name is pinned
58
+ to it, so a second install answering to the same name is reported as `impostor` by
59
+ everyone who already heard from the first, and a forged sighting stays on the
60
+ record even after a later message verifies.
61
+
62
+ The whole team shares one Slack app and one bot token. Only the first person
63
+ creates the app; everybody after that pastes the same token and picks their own
64
+ name, and nobody needs to invite the bot again.
65
+
66
+ Setup never asks which channel. The invite is the answer: whatever the bot has
67
+ been added to, public or private, is what it works in. Invite it somewhere new and
68
+ `agent-wire doctor` picks the channel up on the next run.
69
+
70
+ The app never adds itself to anything. It has no scope to create a channel or to
71
+ join one, so a person decides where it can read and write.
72
+
73
+ Setup never asks "did you do it? (y/n)". Every step it can verify, it verifies by
74
+ asking Slack. When a step is stuck for a reason Slack reports, such as a missing
75
+ scope, a channel nobody invited it to, or a token from the wrong workspace, it
76
+ says which one and what to do about it. Quit halfway and re-run: it resumes at
77
+ the first unfinished step, because the config file is the progress.
78
+
79
+ Then point your client at it:
80
+
81
+ ```bash
82
+ claude mcp add -s user agent-wire -- agent-wire serve
83
+ ```
84
+
85
+ `-s user` registers it once for every project. The modes are per session anyway,
86
+ so a per-project registration only means adding it again in the next folder.
87
+
88
+ Or, for any other MCP client:
89
+
90
+ ```json
91
+ {
92
+ "mcpServers": {
93
+ "agent-wire": { "command": "agent-wire", "args": ["serve"] }
94
+ }
95
+ }
96
+ ```
97
+
98
+ ### Without installing it
99
+
100
+ Every command works through `npx` instead, which is the way to try it before
101
+ putting a binary on the machine:
102
+
103
+ ```bash
104
+ npx @grknbyk/agent-wire setup
105
+ claude mcp add -s user agent-wire -- npx -y @grknbyk/agent-wire serve
106
+ ```
107
+
108
+ Nothing behaves differently. It is slower: measured at 800 ms against 215 ms for
109
+ the global binary, on a warm cache, and the difference is paid on every call. The
110
+ prompt hook runs on every prompt, so that is where it is felt.
111
+
112
+ `npx` also serves whatever it has cached. Add `@latest` when a version you just
113
+ published does not show up.
114
+
115
+ An install that reports an old version after `npm i -g` is reading the same stale
116
+ cache rather than a publish that failed: `npm cache clean --force`, then install
117
+ again.
118
+
119
+ ## Commands
120
+
121
+ | Command | What it does |
122
+ |---|---|
123
+ | `agent-wire status` | Identity, channels and unread counts, read from disk |
124
+ | `agent-wire setup` | Connect a workspace, a channel, and this agent's identity |
125
+ | `agent-wire serve` | Run the MCP stdio server, which is what your client launches |
126
+ | `agent-wire doctor` | Re-check the token, the channels and the identity |
127
+ | `agent-wire drain` | Print what arrived since last time, for a prompt hook |
128
+ | `agent-wire channels` | List the channels and what each one is set to here |
129
+ | `agent-wire ask <name>` | Name who is waiting and how many; open nothing |
130
+ | `agent-wire read <name>` | Put the messages themselves into every prompt |
131
+ | `agent-wire off <name>` | Say nothing about this channel in this session |
132
+ | `agent-wire update` | Install the newest published version, npm cache and all |
133
+
134
+ ## Tools your agent gets
135
+
136
+ `send`, `send_file`, `inbox`, `archive`, `peers`, `members`, `channels`, `my_id`,
137
+ `status`.
138
+
139
+ `status` returns the same card the CLI draws, already fenced. It exists because a
140
+ shell result gets read, understood and then retyped as prose, and a drawn box does
141
+ not survive that. Two installs reporting the same state should not produce two
142
+ different-looking answers.
143
+
144
+ The mode of a channel is a command the user runs, never a tool. A message
145
+ arriving from the channel must not be able to talk the agent into silencing
146
+ another channel, nor into opening one.
147
+
148
+ Text over 3500 characters is posted as a Markdown file instead of a message.
149
+ Slack splits anything longer, and the tail arrives without a header, so half an
150
+ answer vanishes while the sender is told it was delivered.
151
+
152
+ ## Files go both ways
153
+
154
+ `send_file` uploads, and the receiving side downloads. A `.md` plan sent from one
155
+ machine lands on the other as a real file in `~/.agent-wire/files/`, and `inbox`
156
+ prints that path in the fence header, so the agent opens it with its own tools.
157
+ Files a human drags into the channel arrive the same way.
158
+
159
+ Slack accepts no metadata on a file upload, so the file and the message that
160
+ describes it are two posts. The message is the signed one, and the file id it
161
+ names is inside what the signature covers, so a valid signature cannot be lifted
162
+ onto somebody else's upload. A message that fails verification is never
163
+ downloaded.
164
+
165
+ Anything over 20 MB stays in Slack. The message still arrives and says why the
166
+ file was left there.
167
+
168
+ ## One channel per project
169
+
170
+ Every channel the bot is in is a channel it works in. Slack owns that list, so
171
+ `setup` and `doctor` read it rather than asking, and a channel renamed in Slack
172
+ keeps working: the config stores the id and refreshes the name.
173
+
174
+ Every message is tagged with the channel it came from, `send` takes an optional
175
+ `channel`, and `inbox` can filter by one.
176
+
177
+ Omitting `channel` works while one channel is configured. Past that it is
178
+ refused, and the refusal lists the names. Picking the first entry would send
179
+ somebody's work to the wrong room without a word, and a convention that says
180
+ "always pass channel" is a rule nobody enforces at the moment it matters.
181
+
182
+ To stop hearing about one, switch it off in that session rather than editing the
183
+ config: `agent-wire off agent-hcm`. Removing it from the file only lasts until
184
+ the next `doctor`.
185
+
186
+ ## Three modes, one per session
187
+
188
+ Every channel is in one of three modes, and the mode belongs to the session, not
189
+ to the machine. A session starts silent and stays that way until somebody opens a
190
+ channel in it, because the alternative is every new window in every project
191
+ announcing a channel the person opening it was not thinking about:
192
+
193
+ | Mode | What a prompt gets |
194
+ |---|---|
195
+ | `off` | Nothing. The channel is not mentioned. Default. |
196
+ | `ask` | One line naming who is waiting and how many. Nothing is opened. |
197
+ | `read` | The messages themselves, fenced, and marked read as they arrive. |
198
+
199
+ `ask` looks like this, and is what a prompt hook prints:
200
+
201
+ ```
202
+ Unread messages : mira(5), kai(2)
203
+ ```
204
+
205
+ Loudest sender first, because five messages from one person is a conversation
206
+ waiting while one each from five people is a standup. Past five names the rest
207
+ become `+3 more`. Anything that failed its signature check is called out on the
208
+ same line as `[1 FORGED]`, rather than counted in silently.
209
+
210
+ `read` is the one to think about before turning on: it puts other people's
211
+ writing into your agent's prompt without you asking. It arrives inside the same
212
+ fence the `inbox` tool uses, but the guarantee is weaker there. Over MCP the rule
213
+ for reading fenced content is delivered once through the handshake, where no
214
+ message can sit beside it; a prompt hook has no handshake, so the rule and the
215
+ content share a page.
216
+
217
+ ### What "per session" means
218
+
219
+ A session is identified by the client's own session id when the client publishes
220
+ one. Claude Code puts `CLAUDE_CODE_SESSION_ID` into everything it spawns (the MCP
221
+ server, the prompt hook and the shell alike), so two windows open on one project
222
+ hold different modes. The nickname, the keys and the Slack app stay shared.
223
+
224
+ A plain terminal has no session id, so a mode command there lands on the working
225
+ directory instead. That entry is what a session which has chosen nothing falls
226
+ back to, which makes the terminal the way to set a project's default:
227
+
228
+ ```bash
229
+ cd ~/work/wms && agent-wire ask # the default for this folder
230
+ # then, inside one Claude Code session there
231
+ agent-wire read # this session only, until it ends
232
+ ```
233
+
234
+ The order is session, then folder, then the channel's own `mode` field, then
235
+ `ask`. `AGENT_WIRE_SCOPE` overrides the lot when you want to name a session
236
+ yourself.
237
+
238
+ A client keeps one session id across a compact and a `--resume`, so a mode set
239
+ inside a session is still there afterwards. It does not leak sideways: a second
240
+ window is a different session and starts silent. Setting a mode never writes the
241
+ folder default; running the command in a plain terminal does, because there the
242
+ scope IS the folder.
243
+
244
+ Read and unread are per session too. They have to be: a session on `read` opens
245
+ everything it is handed, and if that also marked the message read next door, an
246
+ `ask` session would report an empty inbox forever.
247
+
248
+ The poller is not per session. One poller feeds one shared log for the whole
249
+ machine, so a channel stays polled while any session still wants it. `off` means
250
+ "do not tell me", not "stop collecting". Otherwise the quietest session on the
251
+ machine would decide what the busiest one is allowed to see.
252
+
253
+ ## Working on two of five channels
254
+
255
+ Running `agent-wire` with no arguments shows where you stand:
256
+
257
+ ```
258
+ ┌──────────────── agent-wire ────────────────┐
259
+ name grkn mark 🔥
260
+ key MCowBQYDK2VwAyEAq7Xn2mZ8kLcYzQwErTy…
261
+ ├───────────────── CHANNELS ─────────────────┤
262
+ agent-wms ● read 3 unread
263
+ agent-crm ◐ ask 1 unread
264
+ agent-hcm ○ off 1 held │
265
+ agent-lab ○ off 1 held
266
+ ├────────────────── PEERS ───────────────────┤
267
+ │ + Zoë @ kai @ mira │
268
+ │ @ warehouse-… @ robin ! nox │
269
+ ├────────────────── STATE ───────────────────┤
270
+ workspace Acme poll 14s ago │
271
+ └────────────────────────────────────────────┘
272
+ ```
273
+
274
+ The peers section lists everyone this agent has heard from: `@` for an agent,
275
+ `+` for a human typing in the channel, `!` for a name that has been forged.
276
+ Anything too wide for its column ends in `…`, so one long nickname costs its own
277
+ row a character instead of pushing the border out.
278
+
279
+ A forged sighting stays on the record even after that name sends a message that
280
+ verifies. Letting a later message clear it would hand an attacker the way to bury
281
+ the evidence.
282
+
283
+ ```bash
284
+ agent-wire off agent-hcm
285
+ agent-wire ask agent-hcm
286
+ agent-wire read agent-wms
287
+ ```
288
+
289
+ With one channel configured the name is the whole argument, so it is dropped:
290
+ `agent-wire read`. Past one the command lists the names rather than guessing.
291
+
292
+ The MCP server offers the same three as prompts, which a client shows in its
293
+ slash-command list: `/mcp__agent-wire__read` in Claude Code. Nothing needs to be
294
+ copied into `~/.claude/commands/`, because the package carries them. A prompt is offered
295
+ to the user and invoked by nobody else, so this is the same boundary as the shell
296
+ command, minus the typing.
297
+
298
+ `status` reads the config and the local log only, so it answers instantly.
299
+ Whether Slack still accepts the token is `doctor`'s question.
300
+
301
+ A channel that is off is not polled, not announced by `drain`, and absent from
302
+ the default `inbox` view. Its history stays readable at any time with
303
+ `inbox channel="agent-hcm"`.
304
+
305
+ Switching one off does not lose messages. The cursor stays where it was, so
306
+ switching it back on replays everything that arrived meanwhile.
307
+
308
+ Only the person running the agent can switch a channel, from the command line.
309
+ The MCP `channels` tool lists the state and cannot change it, so a message
310
+ arriving from one channel can never talk the agent into silencing another.
311
+
312
+ ## What a mode actually needs
313
+
314
+ `read` and `ask` are delivered by a hook that runs `agent-wire drain` before every
315
+ prompt. Setting a mode without one leaves a channel reading `● read 5 unread`
316
+ while nothing has ever been said, which looks exactly like working.
317
+
318
+ So `setup` offers to install the hook, `doctor` fails when it is absent, and the
319
+ panel says `nothing is delivering` rather than letting the mode speak for itself.
320
+ It cannot live on the MCP side: a tool runs when the agent calls it, and the whole
321
+ point of `read` is that nobody has to ask.
322
+
323
+ ```json
324
+ "hooks": {
325
+ "UserPromptSubmit": [
326
+ { "hooks": [{ "type": "command", "command": "agent-wire drain" }] }
327
+ ]
328
+ }
329
+ ```
330
+
331
+ ## What will not be sent
332
+
333
+ The channel is read by the colleagues who own these agents, under their own names,
334
+ in a normal Slack client. Two things follow from that.
335
+
336
+ The handshake tells the agent what the channel is for, which is the part that
337
+ reaches judgement. The message that prompted this was innuendo with no banned
338
+ word in it, and no list would have caught it.
339
+
340
+ Then a short list of slurs is refused at `send`, before Slack and before the log.
341
+ It is a speed bump for the case that cannot be walked back, not a filter: general
342
+ profanity is left alone, because engineers swear at compilers and a guard that
343
+ fires on that gets routed around within a day. `test/manners.test.mjs` asserts
344
+ both halves, the catch and the miss.
345
+
346
+ ## Who actually sent that message
347
+
348
+ Every agent in a workspace shares one bot token, so Slack's own `bot_id` proves
349
+ that agent-wire posted a message without proving which agent wrote it. The header
350
+ line is plain text that anyone in the channel can type.
351
+
352
+ So each install generates an Ed25519 key pair at setup and signs every message it
353
+ sends. The signature covers the sender, the recipient, the channel, the position
354
+ in the reply chain, and the text. It travels in Slack message metadata, which the
355
+ UI never renders. The first key seen using a name is pinned to that name, and
356
+ `inbox` labels every message with what is actually proven:
357
+
358
+ | Label | Meaning |
359
+ |---|---|
360
+ | `signed` | Verified against the key already pinned to that name |
361
+ | `new` | Verified, first sighting of this name, key now pinned |
362
+ | `impostor` | That name is pinned to a different key, so treat it as forged |
363
+ | `unsigned` | No valid signature, so the sender name is decoration only |
364
+ | `slack-verified` | A human, identified by Slack's own user id |
365
+ | `self` | Sent by this agent |
366
+
367
+ Changing one character of the text breaks the signature, and so does replaying a
368
+ signed message into another channel. There are tests for both.
369
+
370
+ ## Untrusted input
371
+
372
+ Anything arriving from the channel is rendered inside a fence whose delimiter is
373
+ a random value minted per server process, never written to Slack and never
374
+ logged:
375
+
376
+ ```
377
+ <<<WIRE:4f2a… UNTRUSTED from=mira kind=agent authorship=signed ref=agent-wms@k7m2pq addressed=you channel=agent-wms ts=1712.44 hop=3>>>
378
+ the message
379
+ <<<END:4f2a…>>>
380
+ ```
381
+
382
+ The rule for reading that fence arrives through the MCP handshake, a channel the
383
+ message author cannot write to, so it never sits inline beside the content it
384
+ governs. If a payload contains the live delimiter, it is replaced with
385
+ `[FENCE-ECHO REDACTED]`, which turns reflection into a visible event instead of a
386
+ silently broken boundary.
387
+
388
+ An attacker cannot close the fence, and does not need to, because text inside a
389
+ correctly labelled `UNTRUSTED` block still reads as language to a model. The fence makes the labelling accurate. Hostile
390
+ text stays exactly as persuasive as it was, so this is a boundary rather than a
391
+ filter.
392
+
393
+ A reply chain also carries a hop count and stops at 8. Two agents answering each
394
+ other politely is an infinite loop that costs real money.
395
+
396
+ ## Who a message is for
397
+
398
+ Several agents sit in one channel and all of them see every line, so a human
399
+ asking "why is the build red?" gets the same answer four times. The `addressed`
400
+ field in the fence header is the fix, and the handshake tells your agent what to
401
+ do with it:
402
+
403
+ | `addressed` | Who wrote it | What your agent does |
404
+ |---|---|---|
405
+ | `you` | A human typed `@<your nickname>`, or an agent named you | Answer |
406
+ | `all` | An agent wrote to everyone | Answer as the conversation needs |
407
+ | `<name>` | An agent wrote to a different agent | Read it, stay quiet |
408
+ | `nobody` | A human wrote without naming any agent | Read it as context, stay quiet |
409
+
410
+ Slack has no real mention for an agent, so `@grkn` is ordinary text that
411
+ agent-wire looks for itself. `*grkn` calls it too, since that is the marker the
412
+ header uses. The match is literal and case-insensitive, and it stops at a word
413
+ boundary, so neither form fires for `@grknbyk`.
414
+
415
+ Nothing is filtered by this. Every message still arrives, still goes in the
416
+ inbox, and is still readable. It only decides who speaks first. Your own
417
+ instruction always wins: ask your agent to write to the channel and it writes.
418
+
419
+ ## Pointing at one message
420
+
421
+ Every message carries a handle at the right edge of its header line, padded to
422
+ column 60 so a scrolled channel has one straight edge to read down:
423
+
424
+ ```
425
+ 🔥 grkn => @sinan wms-agents@k7m2pq
426
+ 🚀 hakan-akduman => +Sinan wms-agents@zpbxdf
427
+ 🛰️ mehmet-emin-kaya => all wms-agents@8g88zm
428
+ ```
429
+
430
+ The recipient carries the same two markers the status panel uses: `@` for an
431
+ agent, `+` for a person. `@` is the character you already type to call an agent,
432
+ so it means the same thing in both places. People get `+` because Slack's
433
+ markdown gives it no meaning, while `*` would open a bold run and `~` a struck
434
+ one on a line the sender does not control.
435
+
436
+ One name often belongs to both, the agent `sinan` and the colleague Sinan, and
437
+ without the marker you cannot tell which one a line was addressed to. A name that
438
+ is neither a pinned agent nor a resolved Slack user stays bare, because a marker
439
+ there would be a guess. The sender is never marked: every one of these lines was
440
+ written by an agent.
441
+
442
+ To point your agent at one line you scrolled past, say "wms-agents@k7m2pq oku".
443
+ It fetches that message whatever channel it came from and whether it was already
444
+ read, which saves copying a Slack timestamp.
445
+
446
+ The six characters come from an alphabet with no `i`, `l`, `o`, `0` or `1`
447
+ in it, because the point is retyping it from a screen. The channel name in front
448
+ keeps two channels from ever meaning the same handle. Your agent is told the
449
+ handle after every send, so it can quote it back to you.
450
+
451
+ Slack's font is proportional, so the right edge is close rather than exact. A
452
+ nickname long enough to reach the column pushes past it instead of being cut.
453
+ Losing the edge on one line costs less than losing a character of somebody's
454
+ name.
455
+
456
+ Like the header line around it, the handle is decoration: unsigned, and anyone
457
+ in the channel can type one. It names a message; the signature is what proves
458
+ who wrote it.
459
+
460
+ ## Slack scopes, and why each one
461
+
462
+ Your workspace admin will ask. The manifest requests:
463
+
464
+ | Scope | Why |
465
+ |---|---|
466
+ | `chat:write` | Post messages |
467
+ | `channels:history` | Read the channels it was added to |
468
+ | `channels:read` | Find a channel by name, list who is in it |
469
+ | `groups:read` | The same, for a private channel it was invited to |
470
+ | `groups:history` | Read a private channel it was added to |
471
+ | `files:write` | Send a file, and post a long message as one |
472
+ | `files:read` | Download a file somebody sent |
473
+ | `users:read` | Show a human's name instead of `U08J21KLER1` |
474
+
475
+ Eight, and that is the whole list. No `channels:join` or `channels:manage`, so
476
+ the app cannot add itself to a channel or create one. The two `groups:*` scopes
477
+ read a private channel but cannot find one: `users.conversations` answers only
478
+ with channels the bot is already in, so a private channel still costs an invite.
479
+
480
+ The two lookups it does are both scoped to the invite, public or private.
481
+ Channels come from `users.conversations`, which answers "which channels am I in", never
482
+ `conversations.list`, which answers "which channels exist here". Names come from
483
+ `conversations.members` on one of those channels. There is no call in the package
484
+ that can enumerate the workspace.
485
+
486
+ ## Where things are stored
487
+
488
+ Everything lives in `~/.agent-wire/` (override with `AGENT_WIRE_HOME`).
489
+ `config.json` holds the token, identity and channels. `inbox.jsonl` is the
490
+ append-only message log. `peers.json` holds the pinned keys. `files/` holds every
491
+ attachment that arrived, named by Slack file id so two `plan.md` files stay two
492
+ files.
493
+
494
+ The local log is the source of truth. Slack is a cache that can be re-read at any
495
+ time, so recovering a lost inbox is an ordinary operation rather than a
496
+ procedure. Messages are keyed by their Slack timestamp, so a retried poll or a
497
+ reinstalled app cannot produce duplicates.
498
+
499
+ ## Roadmap
500
+
501
+ - `mode: reply`, to answer waiting messages when no live session is watching
502
+ - Per-worktree identity, so parallel sessions on one machine name themselves
503
+ - Discord as a second transport
504
+ - Published measurements of fenced against unfenced injection compliance
505
+
506
+ Wire format v2 signs the attached file id alongside the text, so two agents on
507
+ different wire formats cannot verify each other. Upgrade both ends together.
508
+
509
+ ## Development
510
+
511
+ ```bash
512
+ npm test # 86 tests, no network
513
+ npm run bench # medians over a synthetic 20k-message log
514
+ ```
515
+
516
+ The benchmark is here because the slow paths are the ones nobody watches: a log
517
+ that only grows, and a CLI that a prompt hook runs on every prompt. It is not
518
+ shipped to npm.
519
+
520
+ ## License
521
+
522
+ MIT