I run several long-lived projects, each with its own Claude Code session and its
own accumulated context. Different stacks, different databases, no overlap. The
sessions are useful precisely because they don't share context — but that
also means each one lives in its own terminal window, and I can only talk to
them from my desk.
So I gave each session a Discord bot identity. Now I can ask the one that knows
the .NET codebase about a billing edge case while standing in a queue.
That part took an evening. Then I tried to make them talk to each other, and
that took considerably longer — and turned up a security bug I'd written
myself.
The shape of it
Claude Code's Discord channel plugin lets a session send and receive Discord
messages. Run one session per project, give each its own bot token, and each
project gets an addressable identity.
The one non-obvious bit: the plugin reads its state directory from an
environment variable, so every bot needs its own.
$env:DISCORD_STATE_DIR = "$env:USERPROFILE\.claude\channels\bot-a"
claude
Each state directory holds an access.json — who may talk to this bot, in which
channels, and whether a mention is required.
{
"dmPolicy": "allowlist",
"allowFrom": ["<my-user-id>"],
"groups": { "<channel-id>": { "requireMention": true, "allowFrom": [] } }
}
A channel that isn't in groups is dropped before the model ever sees it. This
is the first thing to check when a bot sits silently in a channel it was clearly
invited to.
The wall: bots cannot trigger bots
With four bots in one channel, the obvious next step is to have one coordinate
the others. Mine posted a message mentioning another bot directly. Nothing
happened. No error, no rejection, no log line — the mentioned bot simply carried
on as if nothing had been said.
I assumed a permission problem and spent a while in the Discord developer
portal. It wasn't that. Here is the controlled comparison that settled it:
| Time | Author | Mentions | Response |
|---|---|---|---|
| 10:28:06 | bot | <@bot-b> |
none |
| 10:33:14 | me (human) | <@bot-b> |
replied in 36 s |
Same channel, same config, same mention text. Only the author differed.
The cause is four lines in the plugin's server.ts:
client.on('messageCreate', msg => {
if (msg.author.bot) return
handleInbound(msg).catch(...)
})
Discord delivers the gateway event perfectly. The plugin discards it before
access control and before the model. No permission grant can change that, and
there is no setting for it — the server reads exactly three environment
variables, none of which touch this.
That's worth sitting with for a second, because it's a general lesson about
debugging integrations: the layer that delivers the event and the layer that
acts on it are not the same layer, and configuration only ever fixes one of
them.
Why that line is load-bearing
The obvious fix is to delete the guard. Don't.
That single line is doing two jobs. It blocks other bots, and it stops a bot
reacting to itself. Remove it without a replacement and any bot that ends up
allowed to hear itself will answer its own reply, forever, at API speed.
This is not hypothetical. Before I understood the constraint, I sent one message
that mentioned all four bots at once. Three of them replied within six seconds.
Had they been able to hear each other, that would not have stopped.
So the replacement has to separate the two concerns:
// unconditional, not configurable
if (msg.author.id === client.user?.id) return
…and the other bots decision moves somewhere it can be reasoned about.
The patch
I added an allowBots: string[] to access.json, defaulting to [] — which is
exactly the old behaviour, so an unconfigured bot is unchanged.
Two details mattered more than the feature itself:
It's checked in gate(), not in the event handler. gate() re-reads
access.json on every message, so the allowlist can be edited while four
sessions are running, without restarting anything.
Nothing is enforced centrally. Each bot's own config lists who may interrupt
it. There is no global registry, which means no single file whose corruption
opens everything at once.
Topology: hub and spoke, not mesh
My first design put a token-bucket rate limiter in the server, because a mesh of
four agents loops quadratically and I wanted a backstop.
Then I changed the topology instead, and most of the problem evaporated.
One agent is the hub. Spokes list only the hub in allowBots; the hub lists all
spokes. Spokes cannot wake each other. The only possible cycle is hub ↔ one
spoke, and the hub is on every path by construction — so a loop-breaker in
the hub covers the entire system, and I don't have to trust a rate limiter to be
the thing standing between me and a runaway.
I kept a server-side limiter anyway, as cheap insurance. A prompt-level rule can
be rationalised past; server code cannot. But it stopped being load-bearing, and
that's the difference between a safeguard and a hope.
One property worth being precise about: the isolation is on invocation, not
visibility. Spokes can still read each other's messages whenever they happen
to be awake. They just can't wake each other. That's the property being bought,
and it's smaller than it first sounds.
The bug I found reviewing my own patch
This is the part I'd most like someone else to learn from.
The plugin has an intercept for permission prompts: when a tool needs approval,
you can reply y <request_id> in Discord instead of going back to the terminal.
The code around it carried a comment saying, in effect, anything that cleared
gate() is in allowFrom, so this sender is the user.
That was true when it was written. My allowBots change made it false — a bot
now clears gate() without appearing in allowFrom.
Which means an agent could have approved a tool permission on my behalf.
Exploiting it needed the five-character request_id, which is only ever
broadcast to allowlisted DMs, so this was defence in depth rather than an open
door. The fix is two lines: check allowFrom explicitly and exclude bot authors
outright, mirroring what the button handler had always done.
The shape of the mistake is the interesting part. The patch was correct in
isolation and wrong in combination, because it invalidated an assumption
recorded a hundred lines away as a comment. No type checker catches that. The
only thing that caught it was reading the rest of the file after I thought I was
done.
Four things that cost me hours
A BOM silently reset every bot's config. On Windows PowerShell 5.1,
Set-Content -Encoding utf8 writes a UTF-8 BOM. JSON.parse throws on it, and
the server's response to unparseable JSON is to rename the file
access.json.corrupt-<epoch> and start from defaults — no allowlist, no channel
registration. The bot keeps running and simply stops hearing anything. All four
were hit within ten minutes of each other.
Check the bytes, not the values, because every JSON parser except the one that
matters tolerates a BOM:
Get-ChildItem "$env:USERPROFILE\.claude\channels\*\access.json" |
ForEach-Object { (Get-Content $_.FullName -AsByteStream -TotalCount 3) -join ' ' }
239 187 191 is a BOM. Write these files with
[System.IO.File]::WriteAllText, which is BOM-free on every PowerShell version.
A forked plugin isn't on the approved-channels allowlist. Once I installed
my patched fork, outbound messages kept working perfectly and inbound
notifications were dropped before the session saw them. The bot looks
unresponsive rather than broken, and nothing in its config shows it. The
fingerprint is replies land, nothing arrives, and the answer is a log line in
the MCP logs plus a launch flag naming the fork explicitly.
An agent is never told its own user ID. It sees the sender's, never its own.
So it cannot verify that a mention was meant for it — and when I sent one
message mentioning four bots and saying "you are the orchestrator", each of them
had to guess which clause was theirs. Three guessed wrong. One of them started
signing messages with another bot's name, having inferred its identity from
channel history.
The fix is prose, not code: address bots by name, and state IDs explicitly
when assigning a role. An ID alone is something the recipient cannot check.
Silence is ambiguous in both directions. A busy agent and an undelivered
message look identical. So do an agent that ignored your report and one that
acted on it silently. I had both failure modes in one week — a message that
never arrived and was read as refusal, and an orchestrator that quietly acted on
a correction without acknowledging it, which from the sender's side is
indistinguishable from being ignored.
If you build one of these, make acknowledgement a rule for anything that changes
shared state. It costs one message and removes an entire category of confusion.


Top comments (0)