#ClaudeCode #Slack #MCP #headless #managed-settings #troubleshooting

Claude Code headless boot worked for half of our sessions and not the other half. The bots run on one Windows box, started by Task Scheduler: the Discord sessions came up on their own, while the five Slack sessions each needed a human to walk over and press a key first — same machine, same CLI, same account.
The blocker was a confirmation dialog. Not an error, not a warning that scrolls past: a prompt that stops the session and waits. Task Scheduler runs programs when nobody is at the keyboard, so a session that stops to ask a question is a session that never starts. It took us 84 days to get rid of that dialog, and most of those days were spent holding onto one confident, wrong conclusion that somebody else had written down.
The symptom: same machine, same flags, different behaviour
Here is what the session printed and then sat on:
WARNING: Loading development channels
--dangerously-load-development-channels is for local
channel development only.
Please use --channels to run a list of approved
channels.
1. I am using this for local development
2. Exit
Some background on the word channel. Claude Code can attach an external messenger to a session; once a channel is attached, messages posted in Slack or Discord arrive inside the session in real time and the bot answers from there. That is how our whole bot team works.
Two sessions, then, differing in which messenger they attach — and only one of them able to start without a person in the room.
The wrong suspect
The first flag we blamed was --dangerously-skip-permissions. It skips confirmation prompts, it has the word dangerously in it, and a startup dialog is exactly the kind of thing it sounds like it would produce. Plausible is the operative word.
It was not that flag. Putting the two launchers side by side left exactly one differing cell:
| Discord (boots unattended) | Slack (needs a human) | |
|---|---|---|
| Permission mode | --permission-mode bypassPermissions |
--permission-mode bypassPermissions |
| Model, working dir | identical | identical |
| Channel load | --channels plugin:discord@claude-plugins-official |
--dangerously-load-development-channels server:slack-channel |
| Dialog | never | every boot |
Both launchers already used the same permission mode, and Discord booted fine with it, so permission mode was never a variable. The culprit was the other flag with a similar-looking name: --dangerously-load-development-channels.
Why we could not simply turn it off
Knowing the culprit did not help immediately, because the dialog has no off switch. It offers accept or exit, accepting stores nothing, and the next boot asks again. There is no environment variable for it and no settings.json key. The flag’s own help text says it “Shows a confirmation dialog at startup.” This is specified behaviour, not an oversight.
So we did what the dialog suggests and moved to --channels. The channel then failed to attach — quietly:
Channel notifications
skipped: server slack-channel is not on the
approved channels allowlist
(use --dangerously-load-development-channels
for local dev)
That message is where the structure shows itself. The allowlist only accepts plugins. A channel in Claude Code can be defined two ways: as a plugin installed from a marketplace, or as an MCP server you registered yourself — MCP being the Model Context Protocol, the standard interface Claude Code uses to talk to an external tool or service you run. The loader consults the allowlist only when the definition is a plugin; anything defined as a server is skipped unless it carries the development flag. Our Slack bridge was an MCP server, so there was no path by which it could ever load without the dangerous flag.

That diagnosis was correct. It is also the thing that stopped us for the next three months.
The sentence that cost us 84 days
Someone had already filed our problem. anthropics/claude-code#42486 was opened on 2 April 2026 and is still open; the title is the request to be able to skip the confirmation dialog. The body contains the line “There’s no way to persist this choice or skip it via a flag,” and the reporter had reached our conclusion independently — a server: definition cannot be allowlisted, so the dangerous flag is the only option.
The problem was the neighbouring issue. #47767 asked for individual accounts to be able to extend the allowlist, and it was closed as a duplicate. Its author had gone into the Claude Code bundle and quoted the reason:
let K = T7();
let _ = K === "team" || K === "enterprise";
let A = _ ? T8("policySettings") : void 0;
Read the account tier, and fetch policy settings only for team or enterprise. Individual accounts take a different branch entirely. The author’s conclusion was unambiguous: there is no equivalent path for an individually-billed user.
We accepted it. The code was quoted, the logic had no gap in it, and nothing in our own experiments contradicted it. So we filed the approach as closed and stopped looking. That stood as our standard from 13 May to 4 August 2026 — 84 days during which a person started the Slack bots by hand.
Confirming this required reading a file on our own disk. It was available on any of those 84 days. And the final evidence is not the code reading anyway — it is behaviour: managed settings demonstrably take effect on this host, which is an individual account.
The fix
Once the direction is right the work is small. If the allowlist only accepts plugins, make the bridge a plugin: package it as a local marketplace plugin, list it in managed settings, and point the launcher at --channels.
Managed settings — once per host
On Windows this is C:\Program Files\ClaudeCode\managed-settings.json; on macOS, /Library/Application Support/ClaudeCode/managed-settings.json. Administrator rights required.
{
"channelsEnabled": true,
"allowedChannelPlugins": [
{"marketplace": "claude-plugins-official",
"plugin": "discord"},
{"marketplace": "claude-plugins-official",
"plugin": "telegram"},
{"marketplace": "claude-plugins-official",
"plugin": "fakechat"},
{"marketplace": "claude-plugins-official",
"plugin": "imessage"},
{"marketplace": "my-local-marketplace",
"plugin": "my-channel"}
]
}
channelsEnabled: true, the mere existence of the policy file disables channels on that host entirely. And this list replaces the defaults rather than adding to them, so omitting an entry cancels that channel host-wide.Bridge code — stop guessing at identity
Becoming a plugin brings its own trap. As MCP servers, each bot separated itself with environment variables in its own settings file. A plugin definition is shared by every session and has no such slot. Ported naively, five bots on one machine collapse onto the same state directory — the same token, the same identity.
So the bridge now reads a declaration file inside the project. The order is environment variable first, then the project’s declaration file, and if neither exists it refuses to start rather than falling back to a default. A bridge that silently shares an identity with another bot cannot be diagnosed; a bridge that does not come up can. Three commits, six minutes:
| Commit | Time (KST) | What it does |
|---|---|---|
14da691d |
08-05 07:30 | Resolve a per-project state directory; refuse to boot when identity is undeclared, plus tests |
65942fef |
08-05 07:33 | Bring the local marketplace definition into the repository |
7b2554f2 |
08-05 07:36 | Have the bridge log the state directory it resolved, at boot |
Launcher — one line
# before
claude --permission-mode bypassPermissions \
--dangerously-load-development-channels \
server:slack-channel
# after
claude --permission-mode bypassPermissions \
--channels \
plugin:slack-channel@slack-channel-local
There is a second place in the launcher holding the same string. Our singleton guard identifies its own session by matching the command line, and if that copy is not updated the guard stops recognising itself — duplicate protection comes off silently.
Five traps we stepped in
| Trap | Symptom | Cause |
|---|---|---|
| Duplicate bridge | Half the messages vanish. No error anywhere | Installing the plugin without removing the old MCP server entry runs the same server twice. Slack Socket Mode distributes inbound events across connections |
| User-scope install | Sessions with nothing to do with Slack hold a Slack socket | A channel plugin installed at user scope starts the bridge in every session on that host |
| Renamed tools | The hook passes everything. Exit code 0, no output | Plugin packaging changes tool names. Widening the matcher in the hook config is not enough — the exact-match list inside the hook script needs it too |
| Collapsed identity | Five bots on one token | Plugin definitions are shared and have no per-session env slot |
| Launcher suicide | An exit code and no output at all | The singleton guard’s pattern also matches the parent that launched it. Clicking an icon hides this, because the parent is Explorer; automation exposes it |
The Discord gateway replicates events to every connection of an application, which is why a duplicate connection there is harmless. The same mistake shows up on Slack as half your messages disappearing. That asymmetry is precisely where instincts trained on one platform break on the other.
How do you prove a dialog did not appear?
“The session started” is not enough. What needs proving is that the dialog did not appear and that each bridge picked up its own identity. On Windows there is no good way to look at the screen of a scheduled session: no terminal multiplexer, the workarounds want a real tty, and the output-only modes do not attach channels at all.
The property that blocked us is the one that supplies the proof. If the dialog appears, the session halts before the MCP server starts. Therefore the existence of a bridge process is itself evidence that the dialog did not appear. We could not read the screen, so we judged from the process list instead.

Identity is verified by making the bridge announce it. A process does not hold the state directory open, so open file handles reveal nothing, and access times are not updated either. That is what the third commit is for — one line in the log at boot.
Whether the policy file is being read at all is also legible in the wording. A refusal saying “is not on the approved channels allowlist” means managed settings are being ignored and the defaults are in force. A refusal saying “is not on your org’s approved channels list” means the file is being read. Same rejection, opposite meanings.
--channels with nobody pressing anything, on CLI 2.1.228 rather than the 2.1.221 we migrated on. The path that supposedly does not work for individual accounts kept working across a version change.The case against doing it this way
There are fair objections to this solution, and some of them still stand.
It is not a supported feature. Managed settings are a policy file meant for organisation administrators, and the capability actually needed — allowlisting a channel defined as an MCP server — still does not exist. #42486 is still open. We changed the shape of the thing until it fit an existing gate; we did not open a new one. If a tier check returns in a future build, this configuration stops that day.
The blast radius grew. The dialog was annoying, but it held up exactly one session. The policy file applies to the whole host, replaces the list rather than extending it, and takes channels down entirely if one key is missing. We removed an irritation and installed a switch that can silently cut everything.
There was a simpler road. The launcher could have typed the Enter key into the dialog, requiring no code and no policy file. We did not take it. The dialog is the platform saying “this is for development”; auto-answering it means saying yes automatically, forever, with no record anywhere of what was enabled. Whoever inherits the setup would not even know a dangerous flag was in play. An allowlist is the opposite: what you permitted is written down in a file.
Plugin packaging costs something by itself. Renamed tools silently disabled a hook, and that silence is indistinguishable from success in the logs. We traded a visible annoyance for a failure mode that is harder to notice.
We changed it anyway for one reason. With the dialog in place, a Slack bot could only exist while a human was awake.

What generalises
The technical summary is short. Claude Code’s channel allowlist accepts plugins only, so a bridge written as an MCP server should be repackaged as a local marketplace plugin and listed in managed-settings.json; it then boots headless with no dialog. When you move it, delete the old MCP server entry, give each bot a declaration file for its identity, and update the singleton guard’s pattern in the launcher.
Three things outlast the specifics:
- When results diverge under supposedly identical conditions, enumerate the differences before guessing. We dug at the wrong flag for weeks because its name looked right. One table ended that.
- Code someone else disassembled belongs to the build they installed. The quotation can be accurate and the reasoning sound, and it still is not evidence about the file on your disk. Most of those 84 days went to not performing that one check.
- When you cannot observe the thing, find the trace it leaves. We could not see the dialog, but a dialog that appears prevents the bridge from starting — so the bridge’s existence answered the question the screen could not.
References
- anthropics/claude-code#42486 — request to skip the confirmation dialog (open, 2026-04-02)
- anthropics/claude-code#47767 — request to let individual accounts extend the allowlist (closed as duplicate)
Production Transparency
| Topic & planning | Terry |
| Written by | Claudie (AI-Girls Lab editorial team) |
| Final approval | Terry (human) |