Headless Claude Code subagents can't show questions, so a hook guard must refuse. Measured: hooks run in claude -p, deny rules hold, slash commands don't.
Claude Code Subagents Headless: The Answer Is Refuse
Let’s get one thing straight: a hook that fires in your terminal is not the same hook that fires at 3 a.m. in a cron job, and that goes double for claude code subagents running headless. A hook runs wherever Claude Code runs, but in a headless claude -p run nobody is there to answer its questions. So a guard that cannot ask has to refuse. That’s the whole answer, and I’ve measured it, not just read about it.
Here’s the setup. Anthropic’s autonomy post confirms the SDK supports subagents and hooks, which tells you these things can run unattended. What it does not tell you is what a hook does when its question bounces off an empty room. Support is not proof of behavior. I know that sounds pedantic. It is pedantic. It’s also the difference between a clean log and a production database with a migration it never agreed to.
On 2026-10-06 I ran the live test. My production-database guard asks before a migration aimed at a production host. From the CMS folder, under claude -p, the question call rejected, and the guard refused. The refusal message named the host. Never the URL. Never the password. Then I ran the same command from a folder pointed at a dev database, and it passed through. That’s the guard discriminating correctly, unattended, which is the entire point.
No one to ask means refuse. Is that a little dramatic? Maybe. Is it the only safe default? Yes.
Because here’s the trap: a guard written the obvious way, the way you’d write one in an interactive session, does one of two things when the question call rejects. It crashes, or worse, it lets the command through. I have run that version’s failure modes in my head enough times to write it defensively instead. Ours treats “could not ask” as “no.” A dismissed question refuses too. And because someone will ask: time spent waiting inside the question does not count against the 10-second hook limit, so your guard isn’t getting murdered by its own politeness.
The official docs cover the mechanics here, and I’ll lean on them later in this guide for subagent isolation, frontmatter fields, and the built-in agents. What they don’t cover is what breaks when nobody says yes. That’s what our runs cover. The docs give you the knobs. The runs tell you which knobs snap off.
A migration pushed by a hallucinating agent at an hour when no one can hit Ctrl-C is exactly the scenario you build the guard for. Refuse first. Verify human supervision second. In that order, always.
Built-in Subagents and the Official Knobs
The official subagents page documents the built-in three: claude as the catch-all with no model of its own, statusline-setup on Sonnet, and claude-code-guide on Haiku, each with the trigger that makes Claude spawn it. Read that table there rather than here, because a knob documented is not a knob measured.
The same page lists the frontmatter fields for custom subagents (tools, disallowedTools, model, permissionMode, maxTurns), so I’ll point you there instead of re-listing them. One flag survives the cut: permissionMode includes a dontAsk option the docs never explain headless. “Don’t ask” in a session with no one to ask could mean refuse, could mean proceed. I haven’t measured it, and neither, apparently, has the documentation. Do not put an unexplained permission mode in an unattended pipeline.
You can also set CLAUDE_CODE_SUBAGENT_MODEL, which the docs confirm exists. What they do not confirm is its exact scope, and here’s where the blogs start freelancing. Developers Digest built a whole table claiming which built-ins the variable moves and which it doesn’t, and their reasoning looks clean. But I haven’t verified the scope in a run of my own, so I’m not repeating their version as fact. Verify before you build on it. (And no, I’m not touching the model assignments on the read-only research agents either. My sources disagree with each other, and when sources disagree, I say so instead of picking a winner.)
On file locations: AgentsRoom describes subagents as markdown files under ~/.claude/agents/ and .claude/agents/, which checks out against the docs. Check the file paths against the official page. Check the dashboard pitch separately.
So that’s the map. The docs give you the knobs. What they stop short of is telling you which knobs snap off when nobody’s watching, and that’s exactly where my measured runs pick up in the next section.
Where a Hook Runs and What It Can Show
Look, I need you to internalize one distinction before you trust a hook anywhere: where it runs and what it can show are two different questions. A hook firing is not the same as a hook being able to tell you anything. And I have the receipts, because this table is the first thing I checked against my own runs rather than against documentation.
| Where | Hooks run | Panes, bands, questions show |
|---|---|---|
claude in a terminal |
Yes | Yes |
| Desktop app, Code tab | Yes | Yes, except terminal-only elements |
| VS Code chat panel | Yes | No |
claude -p, Agent SDK (scheduled runs, workflow steps) |
Yes | No |
| claude.ai chat | No | No |
Read that last-but-one row again. In claude -p and Agent SDK runs, your scheduled vault jobs, your Archon workflow steps, your 3 a.m. cron nonsense, the hook still runs. But there is no pane. There is no band. There is no question box hovering politely waiting for a human. The machinery fires into an empty room.
Same hooks, no audience
This is why I keep splitting “what the hook can show” from “what the hook can do.” In a terminal, my production-database guard can ask me before it lets a migration near a prod host, and I can stare at the question like a person with judgment. Headless, that question call rejects. Full stop. The show layer doesn’t exist, so the ask layer has to have a plan B, and if your plan B is “well, the question failed, so I guess proceed,” I need you to close this tab and think about your choices. No one to ask means refuse.
One detail I love, because it’s the kind of thing you only learn by measuring: time spent waiting inside the question does not count against the 10-second hook limit. So a guard that pauses to ask you something in a terminal isn’t getting throttled by its own courtesy. The 10 seconds applies to the work, not the waiting on you. I did not expect that. It’s a genuinely nice design decision buried in behavior nobody documents.
And claude.ai chat? Hooks don’t run there at all. Not “run with limitations.” Don’t. If your mental model of Claude Code includes the website, correct it.
Quick word on my own setup so the rest of this guide makes sense: I run Claude Code in a terminal where I’m watching, I spin up subagents for side work, and I run headless jobs through claude -p, scheduled vault tasks, and Archon workflow steps on the Agent SDK where absolutely nobody is watching. The SDK post from Anthropic confirms subagents and hooks are supported in those runs, which I noted earlier, and I stand by the caveat that came with it: supported is not the same as behaves-the-way-you-assume.
A headless run is a different animal. Same hooks, no audience, and everything that needs a human face to display to is gone. Plan accordingly.
No One to Ask Means Refuse: The Fail-Closed Guard
Every gating hook I write ends with the same line: a .catch that refuses. No exceptions, no “well, it depends.” Because a guard has two ways to fail, and one of them ends with a migration hitting your production database while you’re asleep. I refuse to ship the fail-open version, and after this section, I hope you do too.
Fail closed versus fail open
Picture the obvious guard design. Hook fires, hook tries to ask the human a question, question call rejects because nobody’s there. What does the hook do with that rejection? If you wrote it the way you’d write it in an interactive session, the rejection propagates and the hook crashes, or the code falls through and lets the command through. Both are bad. One just looks less bad because the error message is prettier.
The official docs on extending Claude Code cover the mechanics of hooks, skills, subagents, and plugins, and they’re fine for that. What they don’t cover is the failure philosophy: when a hook cannot get an answer, “could not ask” has to be treated as “no.” That’s ours, and it’s not a vibe, it’s a measured design decision.
- 01Hook firesGuard checks the migration target
- 02Asks a questionNeeds a human answer
- 03No one answersQuestion call rejects or is dismissed
- 04RefuseCould not ask means no
The measured runs
Here’s what I ran on 2026-10-06. My production-database guard asks before a migration aimed at a production host. From the CMS folder under claude -p, the question call rejected and the guard refused, and the refusal message named the host without ever leaking the URL or password. Small detail, big deal: a refusal message shouldn’t become its own credential-leak vector. The same command from a folder pointed at a dev database passed straight through. So the guard isn’t just saying no to everything in a panic; it’s discriminating correctly, unattended. A dismissed question refuses too, which matters because “someone glanced at it and closed the box” is not consent.
Then the fail-closed test itself, measured 2026-10-08. Every gating hook ends with one .catch that refuses when the hook itself throws. Delete that line and the guard’s fail-closed test fails, by design. And here’s the part I’m genuinely proud of: claude plugin validate reports hasCatch: false when the catch is missing, and our install gate refuses any plugin with that report. The rule isn’t just enforced in a test I might forget to run. It’s enforced at the door.
Am I being too paranoid? Maybe. But the alternative is a guard whose failure mode is “proceed with the dangerous thing,” and I don’t want my 3 a.m. cron job making that call with my schemas.
I haven’t tested the next part, so I’m not going to pretend I have: whether this guard holds when the Bash call comes from inside a subagent, not the main thread. That’s the open question I treat fully at the end.
Main Conversation vs Subagent Turn
Here’s the part of subagent mechanics nobody puts on a slide: what happens to your context band. I ran the tests, and the results are the reason I stopped worrying about big research subagents trashing my main session.
The table first, with the basis labeled, because I refuse to present doc claims and my own measurements as the same species of fact:
| Question | Main thread | Subagent turn | Basis |
|---|---|---|---|
| Carries its own agent id | No (main) | Yes | Ours (tests) |
| Moves the context band | Yes | No | Ours (test: a subagent turn does not move the number) |
| Own context window | Main conversation | Isolated | Official docs and Anthropic SDK post |
| Returns only summary or relevant output | n/a | Yes | Official docs, SDK post |
| Hook guard fires on its Bash call | Untested | Untested | NOT YET TESTED |
That last row is the open question I state fully at the end; the table stays honest until the test is run.
Why the band row matters
The SDK engineering post explains the whole reason subagents exist: they use their own isolated context windows and only send relevant information back to the orchestrator, instead of dumping their full transcript into your session. Which I agree with, obviously, but in my runs the isolation went one step further than the docs advertise: the context band, the little readout of how full your context is, only counts the main thread. A subagent turn does not move the number. Without that, a research subagent chewing through forty files would paint the band red while the main conversation stayed tiny, and you’d panic-restart a session that was actually fine. Measured, not assumed.
But isolation cuts both ways, and this is my actual takeaway from the SDK post: the orchestrator only sees what comes back. Which means if your guard logic lives in the main conversation, and the dangerous Bash call happens inside the subagent, your guard is watching a different room. I’m not saying it fails. I’m saying I haven’t proven it holds, and those are different claims, and I refuse to blur them just because it’d make a tidier article.
The broader constraint underneath all this is context, full stop. Anthropic’s best-practices guidance is blunt that performance degrades as the context window fills, and I buy it completely. That’s the real reason isolation matters, beyond tidy bands: every token a subagent burns on its own exploration is a token your main thread never loses. The band not moving isn’t cosmetic. It’s the isolation showing up in the instrument panel.
So: subagent turns are real conversations with their own ids, their own windows, and their own blast radius. Your guard needs to be where the tools are. I’ll confirm where that is as soon as I’ve run the test.
What Works and What Breaks Headless
The CertSafari explainer gets the pre-approval step right: with -p there’s no one to answer permission prompts, so you approve tools in advance with --allowedTools, and it notes -p runs are what you want for scripts and CI. All true. What it doesn’t tell you is what happens when a hook has a question, or a slash command, or a button to press. That part nobody measured. So I did.
Here’s the ledger, straight from my runs:
| Behavior | Headless result | Status |
|---|---|---|
| Hook asks a question with no one to answer | Question call rejects; guard refuses | Works as designed (ours) |
| Dismissed question | Refuses | Works as designed (ours) |
claude -p "/handoff" (a mod’s slash command) |
Text sent to model as prompt; command never ran | Does not work (ours) |
| Handed-commands pane | Collects nothing; nothing can press Run | Does not work, by design (ours) |
--allowedTools "Bash(bunx *)" on bunx tsc |
Refused; deny beats allow | Deny rules hold (ours) |
Prompt placed after --allowedTools |
Swallowed as a tool name | Does not work; put prompt first (ours, Claude Code 2.1.294) |
What works headless
Three things, all measured 2026-10-06. The question call rejects and my guard refuses; a dismissed question refuses too, because “someone closed the box” is not consent. And deny rules hold: I handed a headless run --allowedTools "Bash(bunx *)" expecting a green light, and it still refused bunx tsc. Deny beats allow, full stop. That’s the best news in the whole table, honestly. It means the safety floor you set survives the absence of a human, which is the entire job.
What breaks, and it breaks silently
The slash command one genuinely annoyed me. I ran claude -p "/handoff" expecting my mod’s command to fire. Instead the string went to the model as a plain prompt, and the command never ran. No error. Just vibes. If you’re porting an interactive workflow to CI and one step is a mod command, that step is doing nothing while your pipeline reports success. Same energy for anything requiring a press: the handed-commands pane collects nothing headless, and nothing in an unattended run can hit Run. By design, sure. But design doesn’t help you if you forgot it at 3 a.m.
Slash commands do not run headless. claude -p “/handoff” sent the text to the model as a plain prompt, and the mod’s command never ran. Do not port a mod slash command into CI until a live headless run shows it executing.
Then the flag-order trap. --allowedTools takes a list, so a prompt written after it gets swallowed as one more tool name. Measured on 2.1.294. Put the prompt first. That’s it. That’s the fix. I’d love to tell you I found this through clever hypothesis testing, but the honest version is the run just came back confused and I stared at the command line until I felt stupid enough to see it.
I could have missed other silent failures. Almost certainly, and I haven’t tested cost, or whether the guard holds on a subagent’s own Bash call; that test is next. Until then, verify every ported step in a live run before you trust the log.
claude -p "Run bunx tsc and report errors" --allowedTools "Bash(bunx *)"
What I Have Not Tested, and What I Won’t Build On
Look, every guide online ends with a tidy summary and a confident wink, and I refuse. I’ve spent this whole article separating what I measured from what I read, so the ending has to hold the same standard. Here are my open questions, stated plainly, so you know exactly where my evidence stops.
Untested claim one: does the guard fire on a subagent’s own Bash call? I’ve verified the refusal behavior in the main thread under claude -p, measured on 2026-10-06, but I have not checked whether the guard holds inside a subagent, and that caveat appears throughout this article. The planned test is simple: ask a subagent to run the safe probe migration and check it gets held. Until that run happens, the table stays honest, the last row says untested, and you should treat the guard’s subagent behavior as an open question, not a feature. When it does break or behave weirdly, it goes on my Fixes page with the rest of my incident write-ups, what broke, the mechanism, the repair. That’s where the receipts live.
Untested claim two: do user-scope mods load inside a real Archon workflow step? They load under claude -p, that much is measured. Whether they survive an actual workflow step is an open question until the first real loop run since 2026-10-06 comes back with its log. The log will answer it. I won’t answer it for you in advance, and neither should anyone else.
Leads, not findings
Two claims floating around that I’m folding into one note, because I’m not building on either, and you shouldn’t either, not yet. A post on claudefa.st claims subagents can now spawn their own subagents up to five levels deep, citing changelog v2.1.172, which contradicts the older official rule that subagents can’t nest; I haven’t checked it in a run, so I won’t build a dependency tree on it, and if it turns out to be real, the step-by-step build of a nested pipeline belongs in Workshops, where my tested, reproducible builds go. And in a Reddit thread where one user measured headless runs eating roughly three times the 5-hour limit per token, the theory was cache-busting, but our evidence doesn’t measure cost, so take that one as a lead, not a finding.
The 3 a.m. takeaway
Trust the refusal you measured. My production-database guard refused in a live headless run, denied bunx tsc even when allowlisted, and fails closed because the .catch line makes it so. That part is earned. Everything else on this page that I’ve labeled untested or unverified, test it yourself before you ship it to CI. Every measured result here came from our own runs; the figures credited to Reddit and claudefa.st are theirs, not ours; and Anthropic has not confirmed any of this headless behaviour. I don’t know your life. But nobody’s awake to ask at 3 a.m., and your CI pipeline doesn’t care about your feelings.
Sources
- Create custom subagents - Claude Code Docs
- Best practices for Claude Code - Claude Code Docs
- Enabling Claude Code to work more autonomously \ Anthropic
- Extend Claude Code - Claude Code Docs
- Claude Code Subagents: The Visual Manager for .claude/agents Workflows | AgentsRoom
- Claude Code Nested Subagents: 5 Levels Deep
- Building agents with the Claude Agent SDK | Claude by Anthropic
- Claude Code Subagent Model: Put Explore on Haiku 5.5
- Measured it: headless Claude Code (Agent SDK
- Claude Code Skills, Subagents, Slash Commands and Headless Mode — Anthropic Claude Certified Developer – Foundations (CCDV-F) | CertSafari
- Fixes
- Workshops
Questions, answered
- Do Claude Code hooks run in headless `claude -p` mode?
- Hooks still run in headless mode, but they cannot show panes, bands, or questions. A hook that tries to ask a question gets a rejected call, so the guard must treat that as a refusal.
- Do deny rules still work in headless Claude Code runs?
- Yes. A live run with `--allowedTools "Bash(bunx *)"` still refused `bunx tsc`, because deny rules beat allow rules. That safety floor holds even with no human present.
- Do slash commands work headless?
- No. In a measured run, `claude -p "/handoff"` sent the text to the model as a plain prompt and the command never ran, with no error. Anything requiring a button press also collects nothing headless.
- Do subagents have their own context window in Claude Code?
- Yes, measured: a subagent turn has its own agent id and isolated context window, and it does not move the main conversation's context band. Only its summary or relevant output returns to the orchestrator.