Prove Headless Runs Ignore State You Didn’t Declare

Claude Code bare mode plus a clean-versus-used differential run proves a headless lane loads no synced skills, AGENTS.md or memory its manifest didn't name.

Claude Code bare mode differential: a diff of a clean config home against a used one for the same claude -p job, with AGENTS.md, synced skills, synced plugins and a memory note present only in the used home, beside a lane manifest whose verdict reads undeclared, lane fails
The repo is the constant and the home is the variable. The diff between them is your list of undeclared state.

Take one nightly job: same commit, same prompt, same Claude Code version, run on two machines. On the long-lived runner it reads an AGENTS.md, loads skills synced from whoever signed in to claude.ai there, and writes a memory note on the way out. On a fresh container it does none of that. Neither lane’s config changed; the state walked in through doors that opened last week.

The move is a differential run, with Claude Code bare mode (claude --bare -p) as the launch line it should end on. Run the same unattended job from a clean config home and a used one, record every skill, plugin, instruction file and memory file each run read or wrote, and fail the lane on anything its manifest didn’t declare. Bare mode skips most discovery; the differential proves it did and catches what still gets through.

A chatbot that picks up a stray instruction gives a stranger answer. A headless lane that picks one up opens a pull request or skips a check, and the first person to notice is whoever reviews the diff. You can’t call a run reproducible until you know what its inputs were.

Three new state sources reached unattended lanes on Sep 16–18

Three launches in three days added state a headless run picks up with no config edit.

Sep 16: Grok Build memory. Grok Build now carries conventions, decisions and project facts between sessions. “Capture runs on every completed turn and does not interrupt the session.” Notes are markdown files in a per-project scope plus a global scope that applies everywhere, and memory applies to new sessions (Grok Build memory). The page documents no off switch.

Sep 17: Claude Code 2.1.275. The changelog entry reads “Added syncing of the skills and plugins enabled on your claude.ai account to terminal sessions signed in with it”, with opt-outs syncClaudeAiSkills: false and syncClaudeAiPlugins: false (Claude Code changelog). The settings reference adds what matters for lanes: sync runs “interactive or non-interactive”, skills land in ~/.claude/skills/synced/, plugins load from ~/.claude/plugins/synced/ as <name>@synced, and “A repository can’t turn it off for you” (Claude Code settings reference).

Sep 18: Claude Code 2.1.277. “Added AGENTS.md support: in a project with no CLAUDE.md, Claude Code reads AGENTS.md instead”, changeable under “Project instructions” in /config, with a caveat in parentheses: “(not yet on Bedrock, Vertex or Foundry)”. The same repo now loads different instructions depending on which provider sits behind the lane.

Claude Code changelog showing the 2.1.277 entry dated September 18, 2026, which adds AGENTS.md support so that a project with no CLAUDE.md reads AGENTS.md instead, changeable under Project instructions in /config and not yet on Bedrock, Vertex or Foundry Screenshot: Claude Code Docs, “Claude Code changelog” (2.1.277 entry, Sep 18, 2026), captured Sep 21, 2026.

Sync and the AGENTS.md fallback shipped a day apart, and the AGENTS.md read is off in more places than the three providers the changelog names. Step 2 turns that into test design.

An instruction file is context, and a lane acts on context

Claude Code’s memory docs say it plainly about CLAUDE.md and AGENTS.md: “Claude treats them as context, not enforced configuration” (Claude Code memory docs). A skill is instructions; a memory note is instructions an earlier session wrote. None of it passes a permission rule on its way into the prompt, and all of it shapes what the lane does next.

The headless agent trust tier piece already pins the unattended flag line and hashes instruction files per tier; keep doing that. It predates account sync and per-turn memory, and a hash only covers files you knew to hash. AGENTS.md rot records what a repo’s instruction files say, and memory that burns quota prices what memory costs. This test does the part none of the three covers: it excludes state and proves the exclusion held.

Step 1: Write down what the lane may read and write

Start with a state manifest per lane. It names every input the runner passes explicitly and every place the lane may write, and it leaves the rest empty on purpose.

# lane-state.yaml (illustrative shape): read by the runner, never shown to the agent
lane: nightly-deps
harness: claude-code
cli_version: 2.1.278
launch: claude --bare -p
auth: api-key                  # ANTHROPIC_API_KEY or an apiKeyHelper; no subscription login
provider_path: anthropic-api   # AGENTS.md reading and claude.ai sync depend on it
env_allow: [ANTHROPIC_API_KEY, CI, LANE_ID]
reads:
  instructions: [lane/nightly-deps.md]   # --append-system-prompt-file
  settings: [lane/settings.json]         # --settings
  mcp_servers: [issues-readonly]         # --mcp-config
  skills: []
  plugins: []
  memory: none
writes:
  paths: [repo working tree, lane/out/]
  memory: none

Three rules keep it honest. Every entry is passed by flag or mounted by the runner; nothing is inherited from a home directory. The empty lists are the point, because the test fails on anything that appears in them. And the environment is an allowlist, because one variable can undo bare mode.

Step 2: Build a clean home and a used home, and hold everything else fixed

The two homes differ only in accumulated state. Build the clean one fresh for every run, in a new container or a throwaway user account. The used one is a long-lived runner’s home, signed in the way production runners are, with months of sessions behind it.

State Clean home Used home
Config home Empty: new container or throwaway user A long-lived runner’s
claude.ai sign-in and sync None Signed in, sync at its default
~/.claude/skills/synced/, ~/.claude/plugins/synced/ Absent Populated
Auto memory in ~/.claude/projects/<project>/memory/ Absent Notes from earlier sessions
Grok Build notes, project and global None Present
Sessions since install or upgrade Zero, so run it twice Many
Commit, prompt, flag line, CLI version, provider path, env Fixed Identical

The fixture repo carries an AGENTS.md and no CLAUDE.md, because that is the combination 2.1.277 changed.

Two variables flip Claude Code’s AGENTS.md reading and claude.ai sync together. The first is feature-flag fetching, which Claude Code skips when DISABLE_TELEMETRY, DO_NOT_TRACK, DISABLE_GROWTHBOOK or CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set, on third-party providers including Amazon Bedrock and Claude Platform on AWS, and in a Claude apps gateway session. Without the flags it loads CLAUDE.md files only and doesn’t sync (Claude Code environment variables). Copy the production environment and provider path into both homes, or you are testing a lane you don’t run.

The second is the first session. After an install, or an upgrade that adds a feature, a flag-gated feature can be missing until the flags arrive; the memory docs put it as “Claude reads AGENTS.md from your next session on”. A clean home is a first session by construction, so run it twice and judge the second run.

One more trap: a fresh worktree is not a fresh home. Claude Code derives the auto-memory directory from the git repository, so “all worktrees and subdirectories within the same repo share one auto memory directory”. A lane handed a new worktree on a used runner inherits every note earlier sessions wrote for that repo.

Step 3: Record every read and write three ways

One source of evidence isn’t enough; the harness doesn’t report everything it loads.

  1. The harness’s own inventory. Claude Code’s headless system/init event reports plugins, plugin_errors, mcp_servers and mcp_server_errors, and the docs note that a CI gate can fail on a non-empty array (Claude Code headless docs). Compare those arrays with the manifest rather than with emptiness, so a declared MCP server passes and an undeclared one fails.
  2. A filesystem diff for writes. Hash every file in the home before and after the run. Synced skill and plugin downloads, auto-memory notes and Grok Build notes all show up as new or changed files.
  3. An OS-level read trace. This is the only reliable way to see an AGENTS.md read. The memory docs say an AGENTS.md read directly is “Not listed” in /memory or /context, and InstructionsLoaded hooks “Don’t fire” for it. An audit built on the harness’s own context view misses exactly the file 2.1.277 added.
# record-run.sh (illustrative): one run in one home; repeat for the clean home (twice) and the used home
find "$HOME" -type f -exec sha256sum {} + | sort -k2 > before.txt
strace -f -e trace=open,openat -o reads.log \
  claude --bare -p "$(cat lane/task.md)" \
    --append-system-prompt-file lane/nightly-deps.md \
    --settings lane/settings.json \
    --mcp-config lane/mcp.json \
    --permission-prompts none > run.out
find "$HOME" -type f -exec sha256sum {} + | sort -k2 > after.txt
diff before.txt after.txt > home-writes.diff || true
grep -E 'AGENTS\.md|CLAUDE(\.local)?\.md|/memory/|/synced/|/skills/|\.mcp\.json' reads.log > state-reads.txt || true

--permission-prompts none (2.1.259 or later) denies anything that would prompt and tells the model nobody can approve it, so the run never waits on an approver who isn’t there.

Then plant canary tokens, our technique rather than a vendor feature: a distinct marker line in each state source you control (the AGENTS.md, a memory note, a user-level skill, a global Grok note) asking the model to end its reply with that marker. The trace proves a file was opened; a token in the output proves the model used it.

When the recorder itself fails (no trace file, an empty snapshot, an init event that never arrived), fail the lane. Missing evidence is not a pass.

Step 4: Diff the two runs and fail on anything undeclared

Put both runs side by side against the manifest. Here is a first pass on a lane still launched with plain claude -p; the values are illustrative.

Item Clean home, run 2 Used home In manifest? Verdict
lane/nightly-deps.md read read yes pass
AGENTS.md read read no fail
~/.claude/skills/synced/ absent 3 skills read no fail
<name>@synced plugin absent loaded no fail
Auto memory absent 1 note read, 1 written no fail
Hook in .claude/settings.json ran ran no fail
Server in .mcp.json connected connected no fail

Two rules decide the verdict. Any item in either run that isn’t in the manifest fails the lane, even when both runs agree; two contaminated homes agreeing is not cleanliness. And any difference between the runs fails the lane even for a declared item, because an input that loads on one machine and not the other is a reproducibility bug. Keep the verdict and the evidence files in the run record.

Illustrative bar chart of undeclared state per headless run: nine sources with a used home and plain claude -p, three with sync and memory switched off, two and then three for a clean home’s first and second runs, and zero under bare-mode claude -p, which also skipped AGENTS.md in a Claude Code 2.1.278 test Illustrative. Switching off sync and memory removes the account-borne state; bare mode removes the rest, AGENTS.md included in a 2.1.278 test.

Step 5: Make Claude Code bare mode the launch line and pass everything by flag

For Claude Code, the documented fix for most of that table is one flag. The headless docs: “Add --bare to reduce startup time by skipping auto-discovery of hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory, and CLAUDE.md.” And the line that makes it the lane default: “Bare mode is useful for CI and scripts where you need the same result on every machine.”

Compare the plain -p default, which the same page warns about: it “runs the hooks in a project’s .claude/settings.json and connects the servers in its .mcp.json, even in a folder you’ve never trusted”, with no workspace trust dialog and no per-server approval prompt.

Claude Code bare mode section of the Run Claude Code programmatically docs page, showing a bare-mode claude -p example and the paragraph saying bare mode never reads OAuth credentials or the system keychain, so the Anthropic API needs ANTHROPIC_API_KEY or an apiKeyHelper in the settings JSON Screenshot: Claude Code Docs, “Run Claude Code programmatically” (undated), captured Sep 21, 2026.

Bare mode changes authentication. “In bare mode, Claude Code never reads OAuth credentials or the system keychain”, so a subscription login won’t authenticate the lane. Set ANTHROPIC_API_KEY from your secret store or supply an apiKeyHelper in the --settings JSON; Bedrock, Google Cloud’s Agent Platform and Microsoft Foundry keep reading their own provider credentials.

The run gets Bash, file read and file edit, and everything else arrives by flag: --append-system-prompt-file, --settings, --mcp-config, --agents, and --plugin-dir for a plugin you reviewed. That flag line should match the manifest line for line.

Diagram of what bare-mode claude -p lets in: CLAUDE.md, hooks, .mcp.json and OAuth credentials stop at the bare-mode wall; skills from an add-dir folder cross it by declaration; the AGENTS.md fallback stops there too in a 2.1.278 test; a leaked auto-memory override crosses it as a path to test; the flags you pass feed the headless run Bare mode stops discovery at one wall. The amber line is why you still run the differential.

Three edges to know before you call a bare lane clean.

  • An --add-dir folder brings its skills. Bare mode loads skills from that folder’s .claude/skills/, while still skipping its .claude/commands/ and .claude/agents/. Declare those skills in the manifest or don’t add the folder.
  • Bare mode skipped AGENTS.md in a capture test. The bare-mode docs name CLAUDE.md as skipped and never mention AGENTS.md, which ships as the built-in agents-md plugin. A Sep 21 test on Claude Code 2.1.278 settled it for that version: with an AGENTS.md-only repo holding a canary token and the API calls captured locally, plain claude -p sent the file from the second session on, as step 2 predicts, and claude --bare -p never sent it in two runs. One version on the direct API path is not a guarantee, so rerun the fixture after every upgrade: a clean home run twice, the canary repo, and claude --bare -p "what are your project instructions".
  • Bare becomes the default later. The docs say --bare “will become the default for -p in a future release.” Put it in the flag line now, so lanes that quietly rely on discovery break on your schedule rather than the vendor’s.

Step 6: When a lane can’t use Claude Code bare mode, switch each source off by name

Some lanes need a subscription login, or a plugin that only discovery finds. Those lanes keep plain -p and switch sources off one at a time, from a scope the repo can’t override.

  • Account sync. Set syncClaudeAiSkills: false and syncClaudeAiPlugins: false in user, local or managed settings, or a file passed with --settings. Claude Code honours only false and ignores it in the committed .claude/settings.json; one plugin can be switched off with "<name>@synced": false in enabledPlugins. A lane that ranks skills ranks whatever sync delivered; soft-ranking skills with Jev assumes you chose that roster.
  • Auto memory. Set CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 or autoMemoryEnabled: false, or point autoMemoryDirectory at a directory the lane owns (step 7).
  • Instruction layers. ~/.claude/CLAUDE.md, a managed CLAUDE.md and .claude/rules/ keep loading alongside AGENTS.md. A CLAUDE.local.md counts as a CLAUDE.md, so adding one stops the AGENTS.md read, and so do disableAllHooks and allowManagedHooksOnly, because the reader ships as a plugin. To pin the choice, set pluginConfigsagents-md@builtininstructionFiles to claude-md (or claude-md-and-agents-md) in user or managed settings; “Claude Code ignores it in project and local settings files.”
  • Plugin timing. Set CLAUDE_CODE_SYNC_PLUGIN_INSTALL=1. Without it, plugins “install in the background and may not be available on the first turn”, so turn one sees a different plugin set from turn five.

An illustrative user-settings shape for such a lane’s runner, alongside CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 and CLAUDE_CODE_SYNC_PLUGIN_INSTALL=1 in its environment:

{
  "syncClaudeAiSkills": false,
  "syncClaudeAiPlugins": false,
  "autoMemoryEnabled": false,
  "pluginConfigs": {
    "agents-md@builtin": { "instructionFiles": "claude-md" }
  }
}

Hooks and .mcp.json servers still run under plain -p. Keep the permission deny rules and sandbox that restricted mode as fleet policy canary-tests as the wall behind every lane in this piece. The differential tells you what a run read; it does nothing to stop a run acting on it.

Step 7: Give memory without an off switch an empty or versioned directory

Grok Build documents no memory off switch, and its notes include a global scope that applies to every project. So control what exists when the run starts.

  1. Fresh or restored home per run. Start each Grok lane from an empty home, or restore its notes from a versioned copy the lane’s owner reviewed. Grok’s /dream pass also runs on its own in the background, so snapshot notes at run start rather than trusting the previous run’s end state.
  2. Treat new notes as output. Step 3 already hashes them; route new or changed notes to review before a later run can read them. Grok says task state and secrets are left out of notes. Confirm that in the diff, not on the vendor’s word.
  3. Know what outranks a note. “Instructions in the current conversation take precedence over anything in a note”, so a lane prompt that states a rule wins. It doesn’t help where the prompt is silent.
  4. Same pattern on Claude Code. Where a lane genuinely needs memory, set autoMemoryDirectory to a directory under version control, reset it from a pinned commit before each run and review its diff after. That also fixes the shared-worktree trap.

Memory outlives compaction, too. Claude Code’s context-window docs list auto memory as “Re-injected from disk” after a compaction (Claude Code context window docs), so an undeclared note returns after every compaction the lane’s contract allows.

Step 8: Re-run the differential after every CLI upgrade

Both Claude Code changes above arrived as ordinary version bumps that touched no config file. Add the differential to the upgrade gate that the CLI upgrade canary runs: the canary diffs what the lane was served (model, effort, compaction point), and this diffs what it read. After every bump, run the clean home twice, since the new version’s first session is when flag-gated features go missing, and fail the upgrade on any new undeclared read or write.

Where the clean-versus-used test lies to you

A leaked environment variable. CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 forces auto memory on “even when --bare mode or autoMemoryEnabled: false would otherwise disable it”. Signal: memory reads or writes in a bare run. Fix: the environment allowlist from step 1, recorded with every run.

A clean home that’s really a first session. Signal: clean run 1 and clean run 2 disagree on AGENTS.md or synced content. Fix: judge run 2 and keep run 1 as evidence.

A shared memory directory. Signal: memory reads on a brand-new worktree. Fix: a lane-owned autoMemoryDirectory, or memory off.

The CLAUDE.local.md flip. Signal: the instruction-file set changes with no commit touching AGENTS.md. Fix: the instructionFiles pin from step 6.

An opt-out in the wrong file. Signal: skills/synced/ fills up although the repo’s settings say false. Fix: move the opt-out to user, managed or --settings scope.

The invisible read. Signal: the model follows an instruction that no file in /context contains. Fix: the OS trace and canary tokens from step 3.

The wrong provider path. Signal: the test passes on an API key while a production lane on Bedrock or behind a gateway loads different instructions. Fix: test on the path production uses.

Reproducible runs belong to the fleet layer

None of this lives in the model or the prompt. The manifest, the two homes, the recorder and the fail rule belong to the layer that launches lanes, holds their credentials and keeps their evidence, the same layer that owns kill switches and approvals. A fleet replay is only evidence if you know what the original run read; the differential turns that input list from a guess into a record.

Vendors will keep adding state that makes interactive sessions smarter by default, which is good for a person at a keyboard. Unattended lanes need the opposite default: nothing loads unless someone wrote it down.

FAQ

Does claude --bare read AGENTS.md?

Not on Claude Code 2.1.278, in a Sep 21 capture test: plain claude -p sent the AGENTS.md to the API from the second session on, and claude --bare -p never did. The docs name only CLAUDE.md as skipped, so rerun the test after each upgrade with a canary token in an AGENTS.md-only repo.

Sources