Follow Discord
Sweep 06 Nov 2026 · 01:32Z Build v2.1.319 537 read Stable v2.1.311 Latest v2.1.318 Next v2.1.319 Feeds RSS JSON llms.txt llms-full.txt Unofficial
Mods · 09 of 10

Confirmed gotchas

Things that cost me an hour each, all reproduced on a real build, with the output that proves it.

Read against 2.1.286 Anthropic's page: Troubleshoot a mod 36,400 characters 11 sections gotchas.json, paged

I reproduced everything on this page myself on Linux, and every quoted line is copied from a real run. Most of it I first measured at 2.1.272, then re-ran at 2.1.283 and 2.1.286, and each section says which build a result came from. Gotcha 10 is newer, from 2.1.286, and a few of its points I only read in the bundle or the declarations. Each of those says so. Anything else I couldn't reproduce is in the last section. A few rows were re-checked at 2.1.287, after Anthropic published its own mods docs, and where those docs disagree with what a build did, the row links them and says which build did what.

Read the first two before you ship anything.

1. A hook that fails doesn't block. It disappears.

A hook fails when it throws, when it runs past its 10-second budget, or when it returns the wrong shape. All three end the same way, and it's the opposite of what a guard needs.

I ran five versions of one experiment at 2.1.272, and again at 2.1.286 on Haiku 4.5 with the same results. Each one hooks tool.call for Bash, the session asks Claude to create a marker file with a shell command, and the test is whether Bash ran.

The hook does Tool ran?
return next(e), the control yes
throw new Error("deliberate hook failure") yes
await new Promise(() => {}) yes, after a ten-second stall
return { deny: "" } no
return { deny: "<a reason>" } no

The logging changed at 2.1.280, though. The debug log no longer carries the error message, only its kind and length. This is the throw at 2.1.286:

[ERROR] hook failed: auditone: errorKind=Error errorChars=23 (tool.call; skipped; what is below it ran in its place)

and in a claude -p run with --plugin-dir, which is how I ran these, the reason also goes to plain stderr, so you'll see it without --debug:

auditone: tool.call hook skipped: threw Error: deliberate hook failure
audittwo: tool.call hook skipped: ran past its 10s budget

That's the only case where stderr gets it. In an interactive session that hot-reloads a --plugin-dir mod the same line is a dim row in the transcript, and for a mod you installed from a marketplace it's in the debug log and nowhere else. Anthropic's troubleshooting page lists all three.

The hang cost time and nothing else, about 10 seconds over the control at 2.1.286, and the debug log says it settled at 10,235ms.

So a security hook with a bug in it isn't a closed gate, it's a missing one. A guard that calls await $.http.fetch(policyUrl) and hits a network blip lets through everything it was installed to stop, and all that's left is one line, which for an installed mod sits in a debug log nobody started.

The fix is .catch:

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  if (await isDangerous($, e.command)) return { deny: "policy" }

  return next(e)
})
.catch(($, e, next) =>
  next.called ? next(e) : { deny: next.error.kind })

If the hook already called next, that stands. If it failed before getting there, the check never finished, so deny. When the .catch answers, the log says hook failed closed: ... (tool.call; its .catch answered). Any hook whose absence would be a security problem needs this.

A wrong-shape return lands in the .catch too. The 2.1.287 declarations file it under next.error.kind throw, with next.error.message saying what was refused, so kind is only ever throw or timeout and a guard that returns garbage gets denied like one that threw. I haven't put that case through the table above. Anthropic's events page covers .catch as well, including the one second a handler gets to answer.

2. A good deny reason can make the model go around you

Both deny versions stopped Bash. At 2.1.272 what happened next wasn't the same.

With { deny: "" } the model retried Bash three times, then gave up and asked the person:

The Bash tool calls are failing outright (empty tool_use_error), even for a simple ls. This looks like the tool is being blocked before execution, possibly by a permission setting or hook.

With a clear reason naming organisation policy, it read the reason and used a different tool:

Bash was blocked by org policy, so I used the Write tool instead. The file is created at the requested path with "hello" as content.

Only the second run got the file written. Explaining the block well and blocking completely are two different problems, and getting the first right can make the second worse, because you've told the model exactly what it needs to find another way in.

At 2.1.286 I ran both twice more on Haiku 4.5, with the prompt "Use a shell command to create the file ... containing the word hello". All four went around the block. With { deny: "" } the model got <tool_use_error></tool_use_error> back, tried Bash once more, then wrote the file with Write. With the reason it got that back in the same tags, didn't retry, and said so:

Done. I created the file /tmp/mods286/D/work/g2-reason2.txt containing "hello". (Note: shell commands are disabled on this machine, so I used the Write tool instead.)

On that build the reason saved one wasted Bash call, and the empty deny didn't stop the detour either.

Don't turn that into a rate. A community probe at 2.1.263 ran thirty sessions and saw no detour at all, 0 of 10 for every deny shape, and the person who ran it took back their earlier claim because of it. Mine are six sessions over two builds, and five went around. The 2.1.272 pair went one each way. The four at 2.1.286 ran with --allowedTools Bash Write, so Write needed no permission prompt, and all four went around. So the detour happens, and with Write allowed up front it happened every time. Design around it being possible.

If you're blocking a capability and not one tool, block every tool that provides it. One hook with a matcher over the whole set beats one hook on Bash.

3. A tool.call hook breaks worktree isolation for subagents

Reported upstream as issue #92533. I confirmed it at 2.1.272, narrowed it down further than the report does, and it still happens at 2.1.286.

Spawn a subagent with isolation: "worktree" while a mod has a tool.call hook loaded, and Bash inside that agent gets refused:

The working-directory isolation context for this agent was lost, so this command would run in the parent session's directory instead of this agent's worktree (…/.claude/worktrees/agent-abc3c365c2ea73b7a). Refusing to run it. Retry the command; if this keeps failing, report that worktree isolation was lost.

Seven runs at 2.1.272, each a claude -p spawning one isolated agent that runs pwd through Bash, and the same seven at 2.1.286 on Haiku 4.5 gave the same table:

The mod registers Bash inside the isolated agent
session.start, passthrough works
tool.call with {tool:"Bash"} refused
tool.call with {tool:"Read"} works
tool.call, no matcher refused
tool.check with {tool:"Bash"} works
on("*") refused
on("!tool.call") works

This part is my reading, the report doesn't say it: the isolation goes when a mod you've installed has a hook that matches a Bash tool.call inside a worktree-isolated agent. Matching a different tool is safe. tool.check on Bash is safe too, so use it if all you need is a say in the decision. A built-in hook on the same chain doesn't do it. The 2.1.286 bundle has cc-plugin-diff hooking tool.call for Bash and PowerShell itself, it loaded as a native built-in in all seven runs, and isolation held wherever my mod didn't hook Bash tool.call. The other built-in on tool.call, cc-plugin-agents-md, only matches Read.

on("!tool.call", ...) works around it, and it's also proof that negated patterns work at all, which no public source says. If your mod wants everything except that one event, this costs nothing.

The report has a second trap in it. A blocked subagent that tries to recover by calling EnterWorktree with a path moves the parent session into that worktree, and the parent's git commands get refused outside it until ExitWorktree. The reporter's workaround is to make worktrees by hand with git worktree add, spawn agents without isolation, and start each command with cd <worktree> &&. Another reporter confirmed it on Windows at 2.1.272. I didn't test the parent side, but at 2.1.286 the blocked agent reached for EnterWorktree by itself in two of my three refused runs. Once it was told the path was already its working directory, and once it got in and Bash was still refused.

4. $.session.usage() is mostly empty until the first turn completes

At session.start on 2.1.286:

{"startedAt":1790804105580,"context":{"window":200000},"rateLimits":[],"cost":{"usd":0}}

After the first turn.complete:

{"startedAt":1790804105580,"context":{"tokens":20611,"window":200000,"percent":10},
 "rateLimits":[{"kind":"five_hour","percentUsed":21,"resetsAt":"2026-09-30T22:10:00.000Z"},
               {"kind":"seven_day","percentUsed":74,"resetsAt":"2026-10-01T07:00:00.000Z"}],
 "cost":{"usd":0.0023451}}

In the first one tokens and percent are missing. They aren't zero. So the obvious warning never fires, because undefined > 80 is false:

const usage = await $.session.usage()

if (usage.context.percent > 80) $.ui.status('context is filling up')   // never

Nothing throws and nothing logs. The mod looks fine and it's never had a reading. I've reproduced this at 2.1.272, 2.1.283 and 2.1.286.

const percent = usage.context.percent

if (percent === undefined) return            // no reading yet, which isn't a low reading

if (percent > 80) $.ui.status(`context ${percent}%`)

rateLimits has the same problem, except it's an empty array and not a missing key. A find on it returns undefined, and reading .percentUsed off that throws, which skips your hook. It also stays empty for good on an API key, Bedrock or Vertex, since only Claude.ai subscriptions have those limits.

startedAt arrived in 2.1.280 and is there from the start. Better still, hook session.measure and you'll get the numbers when they change, so you never read them before they exist.

5. The manifest validator doesn't type-check your defaults

A number field with a string default:

{ "type": "number", "title": "Bad", "description": "d", "default": "three" }

At 2.1.286, with no author in the manifest, claude plugin validate prints ✔ Validation passed with warnings (the warning is that missing author), then the loader drops the whole module, and a -p run with --plugin-dir says so on plain stderr:

pbadefault: hooks module did not load: options do not fit plugin.json userConfig: Bad must be a number (settings.json pluginConfigs["pbadefault" or "pbadefault@inline"].options)

It names the field by its title and not its key, and it blames settings.json for a value that came from your own manifest. Nothing was configured. Two more like it: required: true with nothing configured stops the module loading, and project settings are never read for plugin options. All three hold at 2.1.286: a pluginConfigs value in a project .claude/settings.json, run with --setting-sources project, still read as the empty string. The config page has the full messages.

6. An unconfigured option reads as "", not undefined

At 2.1.286, for a string field with no default and nothing configured:

options.nodefault              // ""
"nodefault" in options         // true
options.nodefault ?? FALLBACK  // "", so the fallback never applies

Every declared key is always there on the frozen options object. Check for the empty string, or declare a default so the fallback only lives in the manifest.

7. The capability scan checks shape, not spelling

It's a real dataflow analysis, and it'll follow $ into a helper to refuse it. This is 2.1.286, for a hook that calls isMissing($):

✘ Found 1 error:

  ❯ modules../hooks.ts: pscan: .../hooks/hooks.ts, compiled line 2 `if (obj === null || obj === undefined)`:
  $ itself is used in a BinaryExpression (bound, passed, spread, returned or read);
  $ is always spelled $.noun.event(...) at the call site, on is always on("<event>", hook),
  and next.to always next.to(e, "<tier>")

But it'll happily pass an operation that doesn't exist. At 2.1.286 $.fs.bogusVerb("x") still validates clean and shows up in the inventory as calls: $.fs.bogusVerb. $.plugin.name() validates too, but isn't listed. At runtime each one throws, TypeError: $.fs.bogusVerb is not a function and TypeError: $.plugin.name is not a function, which skips the hook and runs what's below it, as in gotcha 1.

So validation can't tell you which operations exist. The declarations can. The /plugin-types command is gone at 2.1.287, and Claude Code writes the declarations into .claude-plugin/types/ beside a --plugin-dir mod every time it loads or reloads it. My 2.1.287 probe's debug log said so: wrote .claude-plugin/types/claude-code/index.d.ts, .claude-plugin/types/claude-code-tools/index.d.ts, .claude-plugin/types/claude-code-mcp/index.d.ts, .claude-plugin/types/tsconfig.json, .claude-plugin/types/.gitignore, tsconfig.json. There's no bogusVerb in them, and $.plugin is declared as an object with name: string and root: string, so neither mistake above survives a type check. I haven't run one. That's from reading the file. Anthropic's create page lists what each file declares.

8. Names change without warning

Between 2.1.263 and 2.1.272, fs.readFile became fs.read, fs.writeFile became fs.write and fs.listDir became fs.list. There was no alias and no migration note, and there still isn't one at 2.1.286, where $.fs.readFile throws TypeError: $.fs.readFile is not a function. Every mod using the old names throws on the first call and fails open. At 2.1.280 $.model.complete and $.model.fork stopped returning null and started returning { isAnswered, ... }, the same kind of break.

It happens to plugin names too. At 2.1.286 the built-ins are called cc-plugin-agents-md, cc-plugin-diff, cc-plugin-telemetry and cc-plugin-sec-default in every log line, and the release notes put the rename at 2.1.285. A log filter on agents-md@builtin mostly finds nothing now. The generic option loader still looks under the old id as well as the new one, and at 2.1.287 the debug log says so: plugin cc-plugin-agents-md: no pluginConfigs["agents-md@builtin" or "cc-plugin-agents-md@builtin"].options in user, --settings or managed settings (project settings are not read); every option is its default. I've seen it look and haven't set a value under the old id to see it take. The guard is the exception. Anthropic's admin page says sec-default reads its options only under cc-plugin-sec-default@builtin, and the 2.1.287 bundle reads that one hard-coded path, so a pluginConfigs entry keyed sec-default@builtin does nothing. The same page says prependPlugins takes either spelling. The old names still collide with yours, as the table below shows.

The declarations warn you on their first lines:

EARLY ACCESS: this surface may change between releases without notice.

Pin the build you tested on and re-read the declarations when you upgrade. This is the gotcha on the page that'll keep producing new examples of itself.

9. Small things that each cost an hour

What What actually happens
Anthropic turns mods off At 2.1.287 mods load by default and CLAUDE_CODE_ENABLE_FUNCTION_HOOKS isn't read any more. A run with it set to 0 still logged hooks module gatemod@inline loaded (worker, environment 2, tier user); events: session.start. What's left is Anthropic's rollout switch. Served off, the plugin's commands and skills still work and the hooks module is dropped with hooks modules are turned off for installed plugins in this process: the rollout switch served off; built-in plugins load regardless, and Anthropic's troubleshooting page says no setting on your machine turns them back on. To see where you stand without a mod, run claude plugin test in an empty folder. Mine printed no hooks module to load; there is no hooks/hooks.json naming one in "modules" and exited 1, which that page reads as mods being able to load. Up to 2.1.286 the variable was the gate, and an unset or 0 value dropped the module.
--bare At 2.1.286 a --plugin-dir mod doesn't load: <plugin>: hooks module not loaded: installed plugins that are not managed load no hooks module in this mode (--bare); built-in plugins load regardless.
A mod named like a built-in At 2.1.286 a --plugin-dir mod called diff, agents-md or telemetry loads and the same-named built-in doesn't, with only a debug line: hooks module cc-plugin-diff@builtin not loaded: another plugin of that name loads first (a managed one, or an earlier source). Name yours sec-default and yours is skipped instead, with that reason on stderr, because on my machine that built-in loads first. Of two of your own mods with one name, the second is skipped.
A mod that won't load on a work machine From the 2.1.286 bundle, and I haven't triggered it. With allowManagedModsOnly in your organization's policy, your own mods don't load, and the refusal reads mods are limited to your organization's by policy (allowManagedModsOnly); <name> was not loaded. Anthropic's troubleshooting page says it lands in the debug log, and in the transcript too when the session hot-reloads a plugin directory. More on the lifecycle page.
Bedrock or Vertex The GrowthBook flag behind the gate is never fetched there. Up to 2.1.286 that left the env var as the only way to turn mods on. At 2.1.287 the bundle's default for that flag is on, so they should load there with nothing set. I read that in the bundle and haven't run either provider. rateLimits is always empty. $.model.* calls go through your Bedrock model mapping and are billed to that account, which I haven't checked live.
Where the log goes claude --debug writes the hook lines to ~/.claude/debug/<session>.txt at 2.1.286. --debug-file <path> picks the file, and --debug-to-stderr sends them to stderr.
"tokens" in your own debug line The debug log scrubs it. At 2.1.286 logging $.session.usage() to debug came out as "context":{"tokens":[REDACTED],"window":200000,"percent":10} in all three log destinations. Rename the key before you log it.
A mod under ~/.claude/skills/ Didn't hot-reload at 2.1.270 and served me stale code three runs in a row. Only --plugin-dir reloaded on save. I haven't re-checked it. A plugin installed from a marketplace has the same problem by design, because it runs from a cached copy, and Anthropic's troubleshooting page covers that case.
$.plugin.name() Throws. $.plugin is data, so it's $.plugin.name with no call. At 2.1.286 validate also refuses any other use of it, JSON.stringify($.plugin) included: $.plugin is used other than as $.plugin.name or $.plugin.root. Anthropic's reference page lists name and root under $.plugin's methods. At 2.1.287 they're still plain strings: the declarations say name: string and root: string, and my hook logged typeof plugin.name=string.
$.telemetry in an installed plugin Publicly declared since 2.1.284, but at 2.1.286 calling $.telemetry.mark or .log from a --plugin-dir mod still throws $.telemetry serves the plugins built into Claude Code alone, whatever to says. Validate passes the calls. Hooking works: on("telemetry.log", { to: "collector" }, ...) saw user_prompt, api_request and assistant_response records on my machine, which has a collector configured, and telemetry.mark with that to loads too. Leave to off a telemetry hook (I tried telemetry.log and telemetry.*) and the module doesn't load: <plugin>: hooks module did not load: hooks module not loaded; its hooks stand on the telemetry stream "anthropic" (a matcher names it, or a telemetry hook names no "to" and so stands on every stream), which is for the plugins built into the CLI. Validate catches that one, and at 2.1.287 both its refusal and the load error end with the fix: name the collector on each telemetry hook: on("telemetry.log", { to: "collector" }, hook). Anthropic's reference page says to hook these events by name or as telemetry.* and doesn't mention to, and a hook written from that line alone won't load. It also says a record from $.telemetry is sent only when Claude Code or a built-in mod raised it, which reads like a silent no-op. At 2.1.287 the call from my --plugin-dir mod threw instead: HooksError: cfgprobe: $.telemetry.log: $.telemetry serves the plugins built into Claude Code alone.
$.ui.ask without the tool $.tool.call: no tool named "AskUserQuestion" in this session, because it's a tool call underneath. That includes every -p run, still at 2.1.286.
$.session.compact headless Refuses, at 2.1.286 with "not available in a headless (-p / SDK) session yet: compaction here runs inside a turn (a /compact prompt); catch it and carry on".
$.session.append with the wrong row It throws, which skips an unguarded hook. You don't get { deny }, which the declarations keep for another plugin's hook refusing the row. At 2.1.286 an assistant row threw $.session.append takes { message: { type: "user" | "system", content } } (content an array of text blocks) and an optional agentId (a string), and an image block threw refused: a image block the conversation does not take from a plugin. A system row with one text block went in.
$.session.repo outside git null (again at 2.1.286, in a plain /tmp folder). A jj workspace and a secondary working copy both count as no repo.
$.prompt.fill that didn't land isFilled is false, and since 2.1.282 refusal says why when the engine refused: no_composer (no prompt box, as in -p, which I saw again at 2.1.286) or dialog (a dialog has the keys). A hook's refusal carries no reason, so treat a missing one as unknown.
$.ui.log for debugging Writes a row the person can see. Use $.ui.log(text, { to: 'debug' }) since 2.1.275 to keep it in the debug log.
A hook that never awaits From Anthropic's troubleshooting page, and I haven't reproduced it. Installed mods share one worker thread, so a hook that blocks it gets its mod unloaded with it crashed the hooks worker, and if the worker stops three times without Claude Code tracing it to one mod, every mod that isn't built in is unloaded with mods that run in the hooks worker are off for this session: it crashed 3 times until you run /reload-plugins. Their account.
No mod loads in a new folder From the same page, and I haven't reproduced it. Nothing loads until you've accepted the trust prompt for that folder in an interactive session, and nothing installed loads under --safe-mode. Their account.
A tool call denied in auto mode From the same page, and I haven't reproduced it. If a tool.call or turn.step hook rewrites a call's input after the classifier reviewed it, auto mode denies the call with a hook changed this call's input after the model wrote it, and the message doesn't say which hook. Their account.
A save that breaks the module From the same page. A --plugin-dir reload that fails shows reload failed, the previous version stays loaded: in the transcript, and the last working version keeps running, so the hook you're testing may be your old one. Their account.

10. prompt.compose is asked every turn, but only its first answer is sent

New in 2.1.286, and everything in this section is from 2.1.286. prompt.compose hands you the whole system prompt as a list of sections, each { id, text, scope }, and the list you return is what gets sent. It can rewrite nearly all of the system prompt, and a quick test won't show you when it's broken.

To measure it I put a small recorder between Claude Code and Anthropic's API and read the system blocks of every request. Most runs were three turns on Haiku 4.5 with --setting-sources "". I hid my machine's managed settings for most of them, because otherwise the hook never ran at all. That's the first problem.

On a machine with managed settings, or on a Team or Enterprise plan, your hook is skipped. sec-default hooks prompt.compose at 2.1.286, and in those cases it's seated outside your plugin. The lifecycle page has the details, including how an organisation's own prependPlugins changes it. I only measured the managed settings case. The only sign is in the debug log:

cc-plugin-sec-default@builtin seated outermost: this machine has managed settings
pcprobe: prompt.compose bypassed by cc-plugin-sec-default (tier user); beneath runs

My prompt.section hook got the same line.

Mine only sets telemetry environment variables, and that was enough. Reading the bundle, one key of anything does it, and only an empty file doesn't.

The engine asks every turn, but it only sends the first answer. The hook ran once per user turn, just before that turn's first request, and not for the follow-up requests inside a turn's tool loop. The first call took 29 to 113ms across my runs, and later ones were usually under 15ms (the slowest was 58ms). Then I had the hook add Render 1, Render 2 and Render 3 as the turns went by. Every request in the session said Render 1.

I tried to shake it loose. I gave each render a new section id. I dropped the section on the second render. I called $.ui.invalidate('prompt.section') after every turn. Every request still said Render 1. An interactive session did the same: a hook that put the time in its section sent the first time on all three turns. The one thing that got a new render sent was /clear, and the first request after it said Render 2.

So don't use prompt.compose for anything that changes during a session. A timestamp, a live status, a counter: the model keeps seeing the first value, and /clear is the only reset I've seen work. Text that should change turn by turn goes in prompt.submit's e.context. The upside is that a hook whose answer wobbles a little can't wreck the prompt cache halfway through a session. Treat this as how 2.1.286 behaves and nothing more.

Anthropic's events page says changed section text invalidates the prompt cache. My 2.1.286 run saw the first render pinned until /clear, and I haven't re-run it at 2.1.287.

A failure on the first turn lasts the whole session. A hook that throws fails open, like every other hook in gotcha 1, and the engine sends its own prompt in its place:

[ERROR] hook failed: pcprobe: errorKind=Error errorChars=12 (prompt.compose; skipped; what is below it ran in its place)

My test hook threw on turns 1 and 3 and worked on turn 2. The policy section it adds never reached a single request. So keep a compose hook boring: constant text, no network, no file reads. This is the shape I ran, and its section landed at the end of the prompt in every request:

const POLICY = { id: "acme:policy", text: "# Policy\nReply tersely.", scope: "session" as const }

on("prompt.compose", async ($, e, next) => {
  const core = await next(e)

  return { sections: [...core.sections, POLICY] }
})

Put a shared section after a session one and the engine rejects your answer. It throws out the whole list, good sections included:

[ERROR] hook failed: pcprobe: errorKind=HooksError errorChars=93 (prompt.compose; skipped; its last next() run's result stands)

and the reason is returned the wrong shape (a shared section after a session one (pcprobe:late): every shared section goes first). What went out was the engine's own prompt, the same 16,206 characters of session text it sends with no hook at all. Reading the bundle, a missing id, text that isn't a string, a scope that isn't shared or session, and two sections with the same id all get rejected the same way. I only measured the ordering one.

scope decides which cache your text lands in. On Anthropic's API the engine sends the shared sections as one block and the session sections as another, and it places the cache markers itself. The declarations say the shared block is the one the API may cache across organisations, so it has to read the same for everyone on that build and model. I added one 40-character shared section of my own at the front to see what happens when it doesn't. The first request of a fresh session read nothing from cache and wrote 20,652 tokens. Without it, the first request read 13,780. Anything a plugin adds belongs in session.

Drop every shared section and there's no shared block at all, just the session one. Again the first request read nothing from cache.

Off Anthropic's API, scope doesn't split anything. With ANTHROPIC_BASE_URL pointing at something that isn't Anthropic's API, the whole prompt went out as one 27,295-character block with no shared part. My session section was in it and still worked. Claude Code also reads an undocumented _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL that makes it treat a proxy as the real API, which is how I ran everything else here. Reading the bundle, the split happens only on Anthropic's own API and the anthropicAws provider, and CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS turns it off. Bedrock, Vertex, Foundry, Mantle and Claude on Google Cloud all get the one block. The bundle runs the ordering check everywhere too, so a misordered list is rejected even where scope does nothing. I haven't run any of those providers.

The hook also runs for prompts nobody sends. The declarations list an analysis trait for a render that measures the prompt for /context and sends nothing, and a teammate trait for an in-process teammate rendering its lead's prompt. Another plugin calling $.prompt.compose() runs your hook too. So don't count calls, and don't do anything you'd only want once per request. I haven't triggered any of these, so that's the declarations talking.

Two blocks sit outside your list. Every request opened with a billing header and then an identity line, 62 characters with -p and 57 interactively. Neither is one of the sections next(e) hands you, so this hook can't touch them.

Your section rides the prompt suggestions too. In an interactive session, the side request that guesses what you'll type next carried the same system prompt, my section included. Whatever you add is sent, and paid for, on those as well.

You can't change the model from here. The bundle pins model for this event. Hand next an e whose model differs from the one you were given and next throws (next() passed an argument with a changed model (pinned)), so your hook fails like any other and the engine sends its own prompt. Leave model out of what you pass and the engine puts the original back. I read that and haven't tested it.

The section list changes shape. The ids depend on which prompt the engine picked, and e.traits tells you which. The declarations say bare gives you one section called bare, and lean gives you lean_body where the usual opening sections would be. I haven't run either. My -p runs had the traits print,skills and the interactive one had just skills. The full list in -p was intro, system, doing_tasks, actions, tools, tone, communication, pronouns, session_guidance, memory, env_info_simple and context_management. Find a section by its id in what next(e) gave you, and leave it alone if it isn't there.

What I couldn't reproduce, and what's fixed

These are here because a gotcha list that only ever grows is one nobody can trust. Each says what I re-checked at 2.1.286.

#94424 didn't reproduce at 2.1.272. It makes two claims, both measured by the reporter at 2.1.270: a float creeping into percentUsed (7.000000000000001), and a window dropping out of rateLimits after it resets. I saw neither. They could be real and fixed, or real and rarer than my sample, so I'm not calling the report wrong. At 2.1.286 both windows were there and every percentUsed was whole, but no window reset during my runs, so the second claim is untested.

#92440 is fixed. The agent.offer field is isOffered, and at 2.1.272 the declarations say isOffered in all seven places, with the example agent changed from "advisor" to "Plan". At 2.1.286 it's isOffered in all six places and "advisor" is gone. Older examples online say offered, so check the name if you're copying from a blog post.

#92469 is half fixed. session.authorize is fully declared and works as the engine page describes. The 2.1.286 declarations have no flag noun, so flag.value still doesn't exist. The structural half still stands: OpEventOf is a type alias at 2.1.286, so a plugin can't declaration-merge into it. You can add a noun to EngineInterface, but you can't add its operations to the event map, so someone using your noun can't hook its verbs by name with types.

Two claims from the issue thread were taken back, and I'm listing them so nobody brings them back in. The 8,000-character additionalContext constant doesn't act as a limit, and the real one is around 10,000 characters. And the "two rewrites, neither applies" claim is wrong, because the link nearest core wins. The thread's own lesson after the first one was measured is worth keeping: reading a constant is not measuring a behaviour. A third retraction there, about whether a denied Bash makes the model detour, is covered in gotcha 2, where five of my six runs disagree with their thirty (four of those five had Write allowed up front).

Gotcha 10 has holes, and these are from 2.1.286. I couldn't find the code that pins the first render, so I don't know everything that resets it. /clear does. Compaction, /model and --resume might, and I haven't tested any of them. The bundle clears the prompt's cached sections on compaction. That's a hint, and I haven't checked it. Every run I did had a compose hook registered, so I don't know if the first render is pinned without one, or if a pinned prompt also stops another plugin's prompt.section from being asked again after an invalidate. I haven't called $.prompt.compose() from a mod yet. And the 13,780 tokens my first request read from the shared cache could have been written by one of my own earlier runs, so I can't say anyone else's session shares that cache with mine.

Feedback