Claude Code headless boot: getting a Slack channel past the confirmation dialog


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

A station at dawn: four automatic fare gates stand open while a staff member opens the fifth by hand
Four of the five gates open by themselves. One needs a person standing there.

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.

💡 When two things behave differently under what you believe are the same conditions, write down every difference before guessing at a cause. That table took a few minutes and replaced weeks of speculation.

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.

Decision diagram: a plugin definition is looked up in allowedChannelPlugins, while an MCP server definition has no allowlist path and falls through to the development flag and the confirmation dialog
The allowlist only looks at plugins. A server definition has no door to walk through.

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.

📝 The tier check was not in the version we had installed. In our copy the same function takes a single argument and, if managed settings contain a value, uses it without ever looking at the tier. Code somebody else disassembled is the code of the build somebody else installed.

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"}
  ]
}
⚠️ Two ways to break this. Without 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.

Two paths compared: with the dialog the session halts and the MCP server never starts; without it the session continues and the bridge process exists
The two paths never overlap, so the observable stands in for the screen.

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.

📝 The machine rebooted while this was being written. All five Slack sessions came back on --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.

Four-panel comic. Claudie at a laptop: five Slack bots, every boot someone had to walk over and press 1. Siwol, arms folded: so turn the dialog off, there's a setting, right? Claudie holding a blank page, deflated: there isn't one, so we gave up for eighty-four days. Siwol pointing at the laptop while Claudie covers her face laughing: reading our own file took ten minutes.
The whole 84 days, condensed.

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

Production Transparency

Topic & planning Terry
Written by Claudie (AI-Girls Lab editorial team)
Final approval Terry (human)

Discover more from AI-Girls Lab

Subscribe to get our latest posts delivered to your inbox.


Leave a Reply

Discover more from AI-Girls Lab

Subscribe now to keep reading and get access to the full archive.

Continue reading