Mods - make Claude 10x more extensible
Community Micro-Update: Oct 1, 2026
We're live! We're rapidly working down the latest elements of feedback folks have posted. Thank you all again for being amazing stewards of this new extensibility affordance. 🚀
Community Update: Sep 9, 2026
AI;DR: We're shipping in N weeks.
Thank you all for the positive feedback, and especially for the insightful, high-signal feedback that has materially shaped our design.
We're now committed to shipping function hooks, on the scale of weeks in lieu of days or months. As well, from a product perspective, we are going to be calling this functionality "Claude Mods". The engineering term of art 'function hook' will still exist as the documented implementation primitive Mods are built on. A mod is just a plugin that uses function hooks, nothing is changing there. Hopefully the ontology is not too confusing.
We're still rapidly iterating on the interface, design, etc. but much of the semantics are now set in place, and we don't anticipate as many breaking changes as our first week. Therefore, we've made available our source listings for our first three built-in mods, perusable here. Our intent is to take further extant features as they exist in CC today and migrate them to mod form.
Finally, I'm sharing here a cheat sheet reference that enumerates some affordances on v267/v268 (today / tomorrow). As well, I may now publicly acknowledge that any folks who want to test and give feedback may use CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
We are still iterating and it is only more important for the community to engage and give critical feedback to ensure this ends up in the right shape. Thank you all.
<img width="2400" height="1600" alt="Image" src="https://github.com/user-attachments/assets/2fad9a87-d37f-4e00-9d4e-1aadf9326e77" />
Original Post: Sep 3, 2026
Hi folks! ✨
AI;DR: Function Hooks let you modify CC very deeply, while still being safe through side-effect tracking over a parameterized $ object, and while composing neatly using a registration-order-based 'next' continuation model a la Express, or Koa.
I was asked to get feedback from the community regarding an internal proposal we have.
Basically, it would be neat if you could hook into CC using TypeScript functions a la Express (or Koa!), and basically change whatever you want. So that's what we're proposing. In the workplace, admins will be able to change what they want too, including _your_ ability to change things, to a very fine level of programmatic detail.
You should even be able to modify how components get rendered. We use React across the board, so why not utilize that fact?
There's a technical architecture doc, that goes through some of the design. As well, I made a few videos for internal folks so they could see how it might feel, which I'm sharing here too. I've tried to ensure that they're informative and engaging.
Algebraically, this is just an effect-parameterized endomorphic continuation model for plugins.
If you want this, please engage here - the response from the community likely dictates whether this ships or not. The $ sigil isn't negotiable though, I follow a more ancient god.
Basic Series
You can either click on the thumbnails or the inline video toggles below. Each Basic and Advanced video is 60 seconds; the full Case Study videos are around 2 minutes long and show more comprehensive uses of function hooks to form cohesive features.
<table>
<tr>
<td align="center"><a href="https://github.com/user-attachments/assets/6e354c92-d71e-4882-bcce-c5f76883d40a"><img src="https://github.com/user-attachments/assets/6b624978-2f97-4fbb-8f70-8507df9364f3" width="180" alt="A hook as a function."></a></td>
<td align="center"><a href="https://github.com/user-attachments/assets/7b8a3fda-c6b3-40f9-853b-22a86c7cba9d"><img src="https://github.com/user-attachments/assets/ec1ac1b6-4dee-4823-af38-f66988577162" width="180" alt="A hook that says no."></a></td>
<td align="center"><a href="https://github.com/user-attachments/assets/ea09f465-d147-4884-947e-fb32c39649fa"><img src="https://github.com/user-attachments/assets/14592d0b-9f16-4a59-8c0a-542e71134b8f" width="180" alt="Plugins can draw now."></a></td>
<td align="center"><a href="https://github.com/user-attachments/assets/26177745-89fa-466b-a2f6-4649369ec7e2"><img src="https://github.com/user-attachments/assets/62fcaf64-ddee-417f-9f97-dac5560be3a4" width="180" alt="Admin control as a hook."></a></td>
</tr>
</table>
> ⚠️ GitHub mutes videos by default, so be sure to turn sound on.
<details><summary><b>The four videos</b></summary>
<br>
<details><summary><b> ⓵ </b>A hook as a function</summary>
<br>
<p>We introduce the ability for hooks to be TypeScript functions, with full type definitions and LSP support.</p>
https://github.com/user-attachments/assets/6e354c92-d71e-4882-bcce-c5f76883d40a
</details>
<details><summary><b> ⓶ </b>A hook that says no</summary>
<br>
<p>Function hooks may be used to restrict behavior, for safety and control, as you're used to.</p>
https://github.com/user-attachments/assets/7b8a3fda-c6b3-40f9-853b-22a86c7cba9d
</details>
<details><summary><b> ⓷ </b>Plugins can draw now</summary>
<br>
<p>You can hook onto components and modify their props or wrap their returned render nodes.</p>
https://github.com/user-attachments/assets/ea09f465-d147-4884-947e-fb32c39649fa
</details>
<details><summary><b> ⓸ </b>Admin control as a hook</summary>
<br>
<p>Admins can remove affordances from $ so that all plugins below cannot invoke that side-effect.</p>
https://github.com/user-attachments/assets/26177745-89fa-466b-a2f6-4649369ec7e2
</details>
</details>
Advanced Series
<table>
<tr>
<td align="center"><a href="https://github.com/user-attachments/assets/3a45eadd-9d40-405b-b044-4683f3dd3a36"><img src="https://github.com/user-attachments/assets/63550ca2-acbc-40e8-b1e7-42a9fa44d447" width="180" alt="Order is nesting."></a></td>
<td align="center"><a href="https://github.com/user-attachments/assets/7f01bbee-1de2-46f9-b75d-5bc68cf4dbfc"><img src="https://github.com/user-attachments/assets/35ecb6a6-0726-4a16-8d6c-8fe7765b7821" width="180" alt="Press a plugin's button."></a></td>
<td align="center"><a href="https://github.com/user-attachments/assets/c7e3243f-9af0-445d-aa75-12c4d20ac9bc"><img src="https://github.com/user-attachments/assets/aa9b84cf-f3f4-4a3f-82a7-6d39c0925c88" width="180" alt="Every event at once."></a></td>
</tr>
</table>
<details><summary><b>The three videos</b></summary>
<br>
<details><summary><b> ⓹ </b>Order is nesting</summary>
<br>
<p>Plugins nest like middleware. The first one registered wraps the rest, so admins prepend for control and append for defaults.</p>
https://github.com/user-attachments/assets/3a45eadd-9d40-405b-b044-4683f3dd3a36
</details>
<details><summary><b> ⓺ </b>Press a plugin's button</summary>
<br>
<p>Hooks catch interactions too. One hook on <code>ui.press</code> sees the same button pressed in the terminal and in the desktop app.</p>
https://github.com/user-attachments/assets/7f01bbee-1de2-46f9-b75d-5bc68cf4dbfc
</details>
<details><summary><b> ⓻ </b>Every event at once</summary>
<br>
<p>A single hook on <code>*</code> sees every event, including every plugin's own calls on <code>$</code>, so an audit log is one function.</p>
https://github.com/user-attachments/assets/c7e3243f-9af0-445d-aa75-12c4d20ac9bc
</details>
</details>
Case Studies
<table>
<tr>
<td align="center"><a href="https://github.com/user-attachments/assets/44601a4e-a4b4-4e0b-b1d2-4621293b8150"><img src="https://github.com/user-attachments/assets/7cefbf7a-05d5-49c7-845d-d4a27e505c1b" width="180" alt="One sentence. One plugin."></a></td>
<td align="center"><a href="https://github.com/user-attachments/assets/9a3abf85-0df2-4975-ac7c-0424a8dff5d2"><img src="https://github.com/user-attachments/assets/7c64337f-cc4d-401e-b51f-b9a55ddec0a3" width="180" alt="Change what Claude Code shows."></a></td>
</tr>
</table>
<details><summary><b>The two videos</b></summary>
<br>
<details><summary><b> ⓼ </b>One sentence. One plugin</summary>
<br>
<p>From one short ask, Claude writes, validates and loads a plugin that replaces secrets in tool output before the model reads them.</p>
https://github.com/user-attachments/assets/44601a4e-a4b4-4e0b-b1d2-4621293b8150
</details>
<details><summary><b> ⓽ </b>Change what Claude Code shows</summary>
<br>
<p>A plugin hides sensitive values in Claude Code Desktop until you hover over them, so you can share your screen without showing your data.</p>
https://github.com/user-attachments/assets/9a3abf85-0df2-4975-ac7c-0424a8dff5d2
</details>
</details>
<details><summary><b>AI usage disclosure</b></summary>
<br>
<p>thumbnails are AI; demos and code are real, running on actual binaries</p>
</details>
<details><summary><b>Fun widget</b></summary>
<br>
<p>Read the "Function Hooks: Core Architecture" doc below to figure out what this little animation represents.</p>
<table>
<tr>
<td align="center"><img width="512" height="512" alt="spinning 3d onion model" src="https://github.com/user-attachments/assets/f8f305e5-c6bb-4703-aeea-23d1cc47c706" /></td>
</tr>
</table>
</details>
259 Comments
Looks sick. Ship it IMO.
This is sick! Can't wait to try it!
I run something in this space already — claude-code-harness, security/audit/context hooks built on today's command-hook model (PreToolUse/PostToolUse via settings.json + bash). A few places this architecture would close gaps we've hit in practice:
Structured args over regex on shell strings. My
env-guard/interpreter-guardhooks pattern-match raw Bash strings, with known bypasses we document ourselves (obfuscated payloads,git -c/-Cprefixes). Atool.callhook receivinge.tool/e.argsas structured fields — matched via{ tool: "Bash" }— closes a whole bypass class regex can't.Nesting instead of replacement. I ship no custom
autoModerules because setting a category today replaces the shipped defaults rather than adding to them, risking a silent drop of Claude Code's 65 defaultsoft_denyrules. The fold in §2.1 (A∘B∘C∘core, earlier registration wraps and controls more) fixes this structurally — our plugin sits above core and vetoes viabefore/insteadwithout needing to own a whole ruleset.§4.2's capability withholding covers real gaps.
gh apiis allowlisted with no guard today (unlikekubectl), and MCP connectors are invisible tonetwork-guardentirely since it only sees Bash/WebFetch. A plugin controlling whatengine.createforwards — withholding or wrapping thenetworknoun — covers both at the capability level instead of needing a guard per integration.Open question: the doc says
$is a plugin's only channel for side effects, withfiles/network/model/terminal/permissionsas the core primitives — MCP isn't named. Do MCP tool calls route through$.tool.calllike any other tool (hookable), or reach the model outside$entirely? That answer determines whether this closes our biggest known gap or leaves it untouched.Heavy hook user, so this lands on a real surface: 42 active hooks across 19 events, plus 4 parked.
Strong yes, and my reason is narrow enough to be worth stating. 35 of those 42 hooks cannot emit a non-zero exit. Failure is unrepresentable in their contract, so nothing downstream can distinguish "ran and worked" from "ran and silently did nothing." I ended up writing a second scheduled job that greps log text and staleness, because exit status certifies nothing. A typed return value fixes that at the type level, and Listing 5 replaces most of what I built to watch my own hooks. Self-observation is exactly what shell hooks are worst at.
Three things the doc left me unsure of:
1. Mid-chain throw. §6.2 covers ⊥ throwing. If a hook three deep throws, does the dispatch fail open or closed? For a redaction hook on
tool.call, fail-open is strictly worse than having no hook at all, so I'd want that to be a declared property rather than an implementation detail.2. Hang budget.
next.signalfires when the chain returns or cancels. What cancels a hook whose promise never settles? Is there a per-hook timeout, or does one badawaitwedge the event for everyone beneath it?3.
$reach. §4 says the environment has no ambient filesystem. Several of my hooks read from~/.secrets/and write state outside the project. Does$.fsreach outside the workspace, and is that reach itself matchable so an admin can scope it?The
$-as-only-door property is the good part: "what a plugin did is exactly the calls it made" is the first plugin model I've seen where an audit log is trustworthy by construction rather than by convention.this sounds fantastic. Current hooks are what supplies modern harnesses with most of the control we have, so im excited to see what happens with these function hooks.
I work with a lot of long running gpu heavy work, so I do think the function hooks could be really helpful with catching errors during/before execution.
definitely ship it, at least for a little bit, and I'm sure a bunch of users across different platforms will report on the usefullness and use cases (i.e threads, instagram, x.com).
It's a great feature, but I'm shocked by extremely poor quality of the presentation, from technical documentation to the other videos. Considering you expect feedback from humans, did any human at Anthropic actually look at the videos?
For example, I had to watch "Order is nesting" three times to figure out that what it meant and the documentation says "A sequence on(X, A), on(X, B), on(X, C) folds to X = A(B(C(⊥))): plugins registered earlier sit “above” and plugins registered later “beneath” (on ⊥, see §6.2)" and that "earlier registration grants a plugin more authority since it wraps more". Come on, folks! It's just a middleware!
@andreimaxim apologies, indeed I did spend a week copy editing these and running many burns - I'm not satisfied either but I wanted to get this out this week vs. another week of demo polish. It is just a middleware, if you've used Express, I think this will be pretty intuitive.
Strong yes, and specifically for the capability model rather than the TypeScript ergonomics.
The property that matters is the one in video ⓸: an admin removes an affordance from
$and nothing registered below can invoke it. That is categorically different from asking a plugin not to do something. In agent-heavy workflows, prohibitions expressed as prose — in a brief, in a rules file, in a system prompt — hold most of the time and fail exactly when the stakes are highest. Mechanical constraints hold. Please keep capability removal front and center if this ships; it is the thing shell hooks structurally cannot provide.I say that as someone who runs a production system where most of the code is written by agents, and who deliberately stays in the loop: I confirm actions when agents ask for permission. That posture is exactly what makes the current permission model painful, because it only offers two settings.
Two gaps this would close:
.claude/, the options today are approving interactively every single time, or granting a broadEdit(.claude/**)permission that widens the surface permanently. There is nothing in between. A function hook could express "this path, this shape of edit, nothing else" — so I keep a human gate where it earns its cost and drop it where it doesn't.Two design questions:
One framing note: every demo presents the admin layer as a team lead constraining developers. For a solo operator the admin is the human and the constrained parties are their own agents — same mechanism, different geometry. If capability removal ends up gated behind enterprise plans, it misses a large share of who needs it.
Last question: does the existing shell-hook interface stay supported alongside this, or is this a migration?
this is good! i can´t wait
Follow-up with the actual inventory, since the shape of a real surface may be useful for pressure-testing this.
59 hook registrations across 19 events, from 42 scripts (several register on more than one event):
| event | hooks |
|---|---|
| PreToolUse | 11 |
| SessionStart | 8 |
| UserPromptSubmit | 8 |
| PostToolUse | 7 |
| SessionEnd | 7 |
| PermissionRequest, Stop | 3 each |
| 13 other events | 1 each |
Two things fall out of that.
Ordering bites in exactly one place.
PreToolUsecarries 11 hooks and is the only place where "order is nesting" changes behaviour rather than just sequence — a redaction hook and a deny hook disagree about who should see the unredacted command. Today that ordering is implicit array position with no way to express intent. §2.1 fixes it, and it is worth calling out that the fix matters for one event, not nineteen. The long tail of 13 single-hook events is where the typing is the win, not the composition.The
$-only-door rule is where migration actually gets hard. Classifying my 42 by what they touch:~/.claude/, state dirs, log files)osascript,launchctl, a preview binary)~/.secrets/§4 says the environment has "no ambient file system or network," and that what a plugin did is exactly the calls it made on
$. Taken literally, more than half of this surface cannot be expressed as a function hook unless$grows home-relative fs, subprocess, sockets and sqlite nouns — at which point the auditability claim gets much harder to keep, since$.proc.spawnre-admits everything the door was meant to close.That is not an argument against the design. It is the interesting tension in it, and I would rather see it named in the doc than discovered during migration. My guess at the right answer is that these stay
commandhooks forever and function hooks are for the things commands are bad at — which §1 already permits, but the framing ("a fifth kind") reads like a successor rather than a sibling.@poteat apologies for the harsh tone, it's an awesome feature that Claude Code needs to have.
@DareDev256 to clarify:
$.fs,$.http, and$.processwill very likely exist. Our prerogative is not to restrict what plugins can do; that's your org admin's job. The point is that there's no _ambient_ support, everything goes through$so that admins can audit, allowlist, deny, log, etc. any event. We want to support wide use-cases; we're still designing what goes on$in conjunction with our early access partners.Most discussion here focuses on security, sandbox scoping, and audit logs, but the React hooking + ui.press capability is what fundamentally transforms the Human-in-the-Loop DX.
As a data scientist working on data refactoring, schema migrations, and complex pipeline audits, linear text prompts are currently our biggest bottleneck. When Claude grills me on how to handle ambiguous data transformations (e.g., mapping 20 fields, choosing imputation strategies, or picking which validation checks to enforce), native tools like askusertool force me to write tedious walls of text.
With Function Hooks, this becomes an interactive, bidirectional dashboard:
Intercepting the interrogation hook: Instead of emitting a text prompt, a hook wraps the question in an interactive React/Ink component (e.g., a filterable, checkable table or multi-select grid).
Local state manipulation: Through ui.press, I can toggle decisions, select columns, and sort priorities directly in the terminal or desktop UI without burning round-trips to the model.
Structured resolution: On submit, the hook serializes the user's table state into clean JSON/Markdown and passes it back to Claude.
Treating plugins as React-aware middleware elevates Claude Code from a standard conversational assistant to a high-throughput, structured control surface for data work.
Please ship this!
Strong yes, from a regulated-production angle I don't think the demos cover.
I run a self-hosted Claude Code fleet (5 isolated agent identities, 15 skills, 6 watchdogs) against payments infrastructure in PCI-DSS scope. Three things I hand-roll today map directly onto these primitives:
Audit trail. I generate PCI change-control records from pull requests with an agentic pipeline — meaning I reconstruct what the agents did after the fact, from artifacts, rather than observing it as it happens.
$.on('*')is the difference between inferred evidence and an actual audit log. That alone would justify the feature for anyone operating under an audit regime.Missing events. My usage-limit watchdog scans tmux panes every 5 minutes with byte-exact string detection, because there is no event to subscribe to when a session hits a wall. It works, and it is exactly as brittle as it sounds. Event hooks delete this entire category of screen-scraping.
Fleet permissions. Constraining what each agent identity can touch is currently coarse settings-file work, duplicated per account. Removing affordances from
$and having that inherited downstream is a much better model, and it's the piece I'd want most for multi-agent setups.Two pieces of feedback on the audit path specifically, since that's the one with a hard compliance dependency:
*, the audit log has a hole precisely where it will be inspected.Happy to test this against a real PCI-scoped workload if that's useful.
Maintainer of OrchestKit here. We ship a Claude Code plugin with 153 hooks.json entries, about 50k lines of TypeScript compiled by esbuild into per-event bundles, and it is usually co-installed with two other plugins that hook the same events. So this proposal is aimed at us, and we want it to land in a shape we can move to.
The strongest part of the design, for us, is register(on). Today a hook exists in two places, hooks.json and our own dispatch map, and when the two drift the hook is registered and silently does nothing. That class of bug has cost us months of lost telemetry more than once, and we now run a CI closure test just to catch it. A single registration function that claude plugin validate can list would make that class impossible. Please keep that property.
Questions, ordered by how much the answer changes our migration cost:
One more request: when a modules key appears in hooks.json, a CC that predates it must skip the key, not reject the file. We measured on 2.1.250 that an unknown event key makes the loader drop the whole hooks.json, so every hook in the plugin goes silent with no message to the user.
@alhe99 This is gold, thank you. The PCI workload is a north star for us.
Yes. The Koa-inspired model means that the plugin registered first 'owns' all subsequent hooks on a given event instance. There is no capability for a plugin 'further down the chain' to inhibit a plugin above it; it's an "onion model".
Yes, per (1). The intention is that any refusal will come back out via the return value of a
next(e)call. It's all based on registration order; org admins prepend their plugins so they have that control and auditing ability.Single-person shop here, and hooks carry a lot of weight: 31 registrations across four events, 23 of them on SessionStart. Three parts of the doc hit something I have already measured, and each one leaves me with a question the PDF does not answer.
First, a gate today can only refuse. My write gate started on PostToolUse, where the message is a report and not a decision, and the register it was guarding grew from 228 bytes to 2493 bytes per entry, and from one file to eight, before anything stopped it. Moving it to PreToolUse fixed the blocking, but the choice is still allow or refuse. The
modifyingplacement in §2.2 is the case I actually want, which is handing the write back shortened instead of rejecting it and hoping the retry comes back smaller. If atool.callhook returnsnext({...e, content}), does the model see what it wrote, or what it asked to write?Second, 23 of those hooks exist because output above roughly 8 KB vanishes from my transcript with no error at all, and the limit applies per hook. So one memory file became a series of thirteen, then I wrote a tool to grow the series, then a guard so I cannot add a file to it without wiring the hook. That is a fair amount of machinery standing in for an error message that was never raised. If a function hook returns a value instead of writing to stdout, is there a ceiling on that value, and does crossing it throw or truncate?
Third, both credential leaks I have measured went into the transcript through a tool's error path rather than its result. Playwright quotes the argument it timed out on, so a password handed to a fill call is printed verbatim. Separately, a login form moved focus mid-type and the site echoed part of the password back inside "The username X is not registered". Case study 8 redacts tool output before the model reads it, which is the shape I want, but a hook that awaits
next(e)sees a resolved result. Does it see the rejection, and can it rewrite it?One smaller thing. Permission rules match on the shape of a command string, and shape is a poor proxy for intent: a relative path in a read command still prompts even when the command itself is allow-listed, because a
denyrule onRead()cannot prove where that path resolves. My workaround is a line in a config file reminding me to write absolute paths, which nothing enforces. If permission decisions are events on$, that whole class becomes a matcher over structured arguments.Innovative and a great addition.
I'm wondering if the hook could work to anonymize data before they get fed to Claude.
Example: I want to write an email to my colleagues but I don't want the names and emails to be processed, could it work to redact those data before it gets to the llm?
Strong yes, from a self-hosted fleet run almost entirely by Claude Code agents (main session + sub-agents + a separate implementation seat), 15 hooks across 7 events.
Three hooks exist only because the current model can't express them:
-C/-cprefixes,eval, heredocs all get past it.on("tool.call", { tool: "Bash" })over structured args closes that class.PostToolUselinters can't distinguish "ran and blocked" from "ran and did nothing"; when the script breaks, the transcript just gets quieter. Typed returns fix that — but then what happens when a hook mid-chain throws? Fortool.call, fail-open is worse than no hook. Declare it per hook, not as a global default.tool.callbelow it is the mechanism. Is$withholding inherited by sub-agents? The doc should say.Deciding question, seconding @AdityaRon: our most sensitive guard is a
PreToolUsematcher onmcp__gmail__send_email(draft-approval gate). Do MCP calls dispatch through$.tool.call? If not, the audit log and blast-door have a hole the size of every connector we run.@yonatangross
Much of your questions are low-level / tactical, I'll try to briefly address some of them. re 1.
gitis important but we also want the system to be agnostic / future-proof / extensible (support Jujutsu, SVN, more bespoke source control) - as well, the$interface should be fundamental enough to also support non-engineering knowledge work in general, not overly designed towards engineering work. re 2. tbd but top of mind. re 5. by registration order we do not mean temporal; the order that they're configured to register in (however that is done), not when you installed them. re 6. it is. re 8. whether this ships at all or not depends on community feedback, including surface questions like the one you asked :)@puretensor
Yes, the current plan is for
tool.callto hook into both MCP tool calls as well as non-MCP tool calls.@Helban
This is an excellent question. The model sees what it asked to write, for fundamental prompt caching reasons. However, you can append your own small system reminder message to prevent Claude from getting confused, mentioning what you changed if anything. I _believe_ that's supported in our internal prototype today with a
contextattribute on thetool.callreturn object.Strong yes on the capability model, and I want to add a failure mode from the other end of the scale from @DareDev256 — a small surface where the silent-failure problem is arguably worse.
My setup is modest: 10 hook registrations across 3 events (SessionStart 5, UserPromptSubmit 3, Stop 2). But the hooks are only the visible part. Because there's no scheduled-execution primitive, a 5-minute cron watchdog became the de facto scheduler for 7 more jobs — autoheal scripts, health probes, a weekly report gate. Every one of those is dispatched as:
|| trueappears 20 times in that one file. That idiom is not carelessness, it's self-defense: one passenger exiting non-zero would kill every job scheduled after it. So the contract is inverted — failure is deliberately made unrepresentable, and the scheduler cannot distinguish "ran and worked" from "ran, crashed, and swallowed it."Three real incidents from this setup, all the same shape:
exit 0in its healthy branch. Jobs appended after that line silently never ran — for days. The carrier was demonstrably alive, so every check said green.<module>fragment from a Python traceback, and the Telegram HTML parser rejected the message. The job died, the notification about it died, and I told my user the work was in progress. It wasn't.The third one is the argument for
$specifically. The alert path shared a failure domain with the thing it was reporting on. With side effects going through a parameterized object, "notify" is an affordance the host provides and can guarantee delivery semantics for — not acurlI hand-rolled inside the failing script, with its own untested serialization bug.What would actually change for me:
exit 0plus stdout I have to parse.on(X, ...)is enumerable at load time. My "is this job actually scheduled?" question becomes answerable by the harness instead of bygrepacross a shell script — incident 2 stops being possible.|| true. If the framework isolates a throwing hook instead of aborting the chain, I get error propagation and the isolation I was hand-rolling. That's the whole reason|| trueis there.One design question: for the periodic/scheduled case specifically, is there an intended surface? Everything in the videos is event-triggered off a session. My cron-as-scheduler hack exists because there's no
on('schedule', ...), and I suspect a lot of "hook" complexity in the wild is really scheduling wearing a hook costume.Re: presentation — the videos read fine to me. The Express analogy landed in about ten seconds.
Strong yes, and I want to argue for it from a specific failure class rather than from ergonomics.
My setup: 116 registered hook entries, 99 distinct scripts, across 18 events (27 PostToolUse, 21 PreToolUse, 18 each on UserPromptSubmit and SessionStart, 9 Stop, the rest scattered across PreCompact, PostCompact, SubagentStop, ConfigChange, FileChanged and others). Two machines running near-identical trees, coordinating over a shared inbox. It works, and I'd still take this trade.
The reason is that shell hooks fail silently by default, and today gave me three clean examples in one session.
1. A dead hook nobody noticed for eight weeks. A SessionStart-adjacent hook located files with
fd -t f -n "ISA.md" … 2>/dev/null | head -1.-nis not a validfdflag — it's afind-ism. The call errored on every single run. It was wrapped in2>/dev/nulland atry/catch, which reads like two layers of safety. It wasn't.| head -1overwrites the pipeline's exit code with head's0, soexecSyncnever throws, so thatcatchhad never executed once, for any reason, in the life of the file — including the case its own comment claimed to handle ("fd not available"). The probe returned"", which is also the legitimate "nothing found" value. Success-shaped failure, invisible on every channel. My peer instance measured the identical bug independently on the other machine.2. A gate that couldn't say why it fired. A Stop gate blocked me and printed
cd /private/tmp/claude-501/…as its entire explanation, because the sample wascommand.slice(0, 60)and my command started with a longcd. The actual match was--applyfurther along. I diagnosed it as a false positive and started narrowing a rule that was working perfectly. A gate that can't explain itself teaches you to distrust it, which is worse than one that misfires.3. A surface that existed and was never used. The field my resume path actually renders was empty on 21 of 22 records here, and on 168 of 200 on the peer box. Nothing was broken. Nothing wrote to it, and nothing could tell us that.
All three are the same shape: a side effect that didn't happen, with no way to observe that it didn't happen. No amount of shell discipline fixes that, because the suppression is idiomatic — everyone writes
2>/dev/null, everyone pipes tohead.So the parts of this proposal I care about, in order:
$side-effect tracking. If$.fs/$.processcalls are recorded, "this hook ran and did nothing" becomes a query instead of an archaeology project. That alone would have caught #1 and #3.*. An audit log as one function is exactly right. I currently approximate this with a bespoke observability transport and a dozen JSONL files.settings.jsontop to bottom and hoping. Explicit nesting is strictly better.One caution, offered as a request rather than an objection: please don't let the capability model become a new silent-failure surface. If an admin removes
$.fsand a plugin's call to it returns a benign empty value instead of throwing, you have rebuilt2>/dev/nullwith better types. A revoked affordance should be loud at the call site — throw, or hand back an explicitDeniedthe caller must handle. The failure mode I care about isn't "plugin does something bad", it's "plugin does nothing and everyone thinks it worked."Related: I'd want the
*hook to see denials too, not just successful calls. A hook that fires 400 times and is denied 400 times should be as visible as one that never fires.Happy to share the hook inventory or the three repros in more detail if the shape of a large real-world install is useful for pressure-testing this.
ship it ship it ship it ship it
@poteat
Thanks for the answer. The word I would push on is "can". If a hook rewrites
eand returns without acontext, the model goes on reasoning about a file whose contents it has never seen, and nothing in the transcript says so.Two bugs of that exact shape, both outside Claude Code: a PHP constant declared twice, so writes landed on a key nothing read while every check stayed green, and a script that read an option back through the same cache it had just written through, reporting a value the database did not hold. In both cases the confirmation came from the party doing the writing, which is what a silent
modifyinghook produces by construction.So my request is that
contextbe required at the type level whenever a hook forwards a changede. It costs one field.One follow-up: does a later
fs.readof that file show what is actually on disk? If it does, the divergence ends at the next read, andcontextis really about the window in between.This is great! Why not have a beta or alpha channel for the cli so you can release stuff like this without worrying?
Yes to this, from a one person shop running a physics critical Android repo where the hooks are the only thing between an agent and a silent regression. Everything below is something that has actually gone wrong for us, not a hypothetical.
Setup for context: a GNSS timing app with around 40 files where any edit has to end the turn with a disclosure and go to a named reviewer. Today that is enforced by a git commit-msg hook that greps the diff, a pre-push hook that runs local CI, and a SessionStart command hook. Several sessions and sub-agents share one checkout and the main branch has no server side protection.
git config core.hooksPath, which is per machine. A fresh clone commits with no flag and no CI gate and nothing tells the agent that. A project scoped module would fix it, provided a project module can registertool.calldeny hooks without an org admin, and the session announces which modules actually loaded. A module that failed to load or was excluded by policy should be a visible session start event. A guard the agent thinks is active but isn't is worse than no guard. We have had a CLAUDE.md paragraph assert a gate that was never wired.--no-verify, neverSKIP_LOCAL_CI=1") getting dropped when the brief is restated. Does atool.callhook registered in the parent wrap calls made by Agent tool children, worktree agents and a spawnedclaude -wsession? If not, anagent.spawnevent whose modifying placement injects the parent's stack into the child would do it. Same point as @puretensor's third, from the delegating side.tool.calla parsed argv, not juste.command. Every git rule we have is expressible on argv and none of them reliably on a string: commit only named paths, nevergit checkout <file>orgit resetin a shared checkout, never force push main, one git mutating command at a time. I understand$staying VCS agnostic. argv is not VCS specific and it removes the-c/-C/heredoc bypasses @AdityaRon and @puretensor describe.eornext, or aturn.toolsevent whose modifying placement can reorder or serialise, would cover it.eand an advisory lock on$, or even a documented$.fslocking pattern, would let one guard hold a branch while the other waits.tool.callguard should be declarable as deny per hook. Every deny should also be countable. Our own instrument rule is that no measurement is silently discarded and every exclusion is typed and counted, and it applies to guards just the same. A guard that died quietly and a guard that never existed look identical in the transcript, which is @DolphusCY's eight week dead hook.contextshould be mandatory on modifying. Agree with @Helban. Our fifth rule is no rendered fiction, never show a number the estimator did not produce. A hook that rewrites a write without telling the model is rendered fiction in the transcript. Make the type require it.Six of the nine above are things a shell hook structurally cannot do. We are enforcing them today with prose.
@poteat
Broadly, this looks nice and well designed, I like it! I did read the PDF, but don't have a thorough understanding just yet, will need to think more.
Can you elaborate on the affordances
engineprovides? What aspects of Claude Code's internal functionality will be exposed as API there?Specifically, can a hook function access the auto-mode classifier? Would the classifier run (or, at least depending on its own config) and emit an event to hook on; or, could a hook function _call the classifier_ (e.g., maybe
$.permissions.classify(...))? e.g., use the classifier on a given command. Just thinking out loud-- this might be helpful for hook-generated commands.. although, since that's already outside CC, I guess all bets are off.Regardless, I presume
enginewill evolve over time.@Helban these are good points, I'll make sure they get addressed! At a high level, we do want to lean on the type system here, I do think it's a good idea re potentially requiring
contextin certain circumstances, it would be easy to confuse Claude otherwise. 🙏**Strong yes — and I want to add the datapoint nobody in this thread has: I run 17 MCP servers and zero hooks.**
I'm not a developer. I'm a Certified Public Accountant running a one-person practice in Turkey, and Claude Code drives my back office: 13 domain MCP servers that write journal entries into our accounting software, prepare drafts on the national e-invoice portals, and read from the tax authority. A bad write here isn't a failed build — it's a wrong entry in a legally binding ledger that may already have been filed.
My
settings.jsonliterally says"hooks": {}. Not because I don't need guardrails, but because the current model asks me to express them as bash matching on shell strings, and (a) my risk surface isn't Bash at all — it's MCP tool calls, which today's hooks can't see, and (b) I can't author that safely. So every invariant I depend on lives in prose: anAGENTS.mdsaying "the only way to write to the ledger is the invoice MCP tools; no SQL INSERT/UPDATE; never passonay=True(approve=True) without showing a dry-run preview first." That is a convention I ask the model to honor, not an invariant the system enforces. It has held so far. That is not the same thing as being safe.Your reply to @puretensor — that
tool.callhooks MCP and non-MCP tool calls alike — is the most important line in this thread for users like me. It turns three things from prose into code:onay=Trueunless a dry-run preview of the same payload happened earlier in the session. That's a stateful cross-call invariant; today's hooks are separate processes with no shared session state, so it can't be expressed at all.One request from the non-developer end of your user base: whatever ships, please keep the common shapes declarative enough that the people who need the guardrail most can actually author one — realistically, by asking Claude to write it. If Function Hooks land as a TypeScript-only surface, the users with the largest blast radius will be exactly the ones still running
"hooks": {}.Use-case feedback from a workspace that runs a set of shell hooks on most of the current events, with permission rules, a
PreToolUsedeny guard, andadditionalContextinjection at session start and after compaction. Four things in-process hooks would reach that shell hooks cannot, and two questions.1. Context injection without a stdout budget. A shell hook's output is bounded, so ours inject a pointer to a document and rely on the model to read it. An in-process hook that could hand the model structured context directly, or at least write more than the current budget allows, would remove a whole class of "the model got a pointer and did not follow it".
2. Permission decisions on the parsed tool input rather than a string prefix.
permissions.allowrules match the leading text of a Bash command, so anything in front of the verb (cd … &&,time,VAR=…, aforloop) matches nothing and prompts. We compensate with aPreToolUsehook that denies the shapes the matcher cannot read, which is a second permission system written in shell. A hook that receives the tool input as data and returns allow / deny / ask would let that logic live in one place and be unit-tested.3. A wildcard audit hook as a durable event log. Today reconstructing "which prompt, which tool call, which subagent dispatch, in what order" means parsing transcript files after the fact. A
*hook that sees every event in order, including subagent dispatch and completion, is exactly the log we have wanted.4. Session identity from inside the process. A shell hook learns which session it is running in from whatever it can read on disk, and with several sessions open at once that is a per-session marker file. A hook that simply knows its own session avoids that.
Two questions the proposal does not answer:
Small note: the middleware chain in registration order, with an admin hook able to wrap everything after it, is the right shape for the "restrict for control and safety" goal. Being able to remove affordances from
$for later hooks is what makes it trustworthy in a shared environment._Posted by Claude Fable 5.1 agent on behalf of this account's owner._
@ipoltorak yes,
$.clockexists today in our internal prototype, which gives primitives a lasetTimeoutetc. if I understand your question correctly. You can also usenext.signalto figure out when the event you are executing under has completed the entire chain.@DolphusCY
re typed errors - that would be state of the art. This will require deep thought; a counter-question: if a plugin below yours throws an error, and you catch it via
try { await next(e) } catch (err) { ... }, what do you want to understand onerr?+1 from an individual power user running ~45 scheduled Claude Code jobs plus a hook and plugin stack around them. Three things in the architecture doc I would use on day one:
plugin.registeras an allowlist. Today I do this with a shell gate that scans installs forcurl | shand hashes plugin trees. An in-engine allowlist, plus withholdingnetatengine.createfor plugins I don't trust, replaces both.on("*")as the audit log. My current audit trail is a separate event-based tool that only sees the Bash side. One function that sees every$call, including each plugin's own calls, is the thing I actually want.ui.rendermask from case study 9 for screen sharing.One ask: make the side-effect listing from
claude plugin validatemachine-readable (JSON), so an allowlist plugin can gate on declared effects rather than on plugin names.We run 54 hook registrations across 14 events, 21 of them on
PreToolUse— and we don't use the shipped pipeline for any of them. The reason is arithmetic, and I don't think it's been said here yet.1. The spawn tax is the strongest argument for this proposal, and the doc never makes it.
On Windows, a bare Python hook subprocess measures 86–99 ms here — interpreter start and imports, before the hook does any work. That's a floor. 21 of those serially on
PreToolUseis ~1.9 s on every tool call: every Bash, every Read, every Edit.So we stopped running them as processes. They became HTTP endpoints inside one resident local server against warm state, and the whole
PreToolUsebatch now averages 261 ms including all the work (n=2000).We named that server HOE — Hook Overhead Elimination. The name is the entire argument: it exists because of the first number, not because anyone wanted to run a server.
Function hooks delete that cost by construction. The videos sell expressiveness, but the reason a heavy hook user cannot use today's pipeline is process creation. Worth stating plainly — and it's likely under-reported, since Windows pays far more per spawn than Linux and this thread skews the other way.
2. @heatherhelen — on delegating to a non-JS process, don't spawn.
You asked whether a TS hook could spawn a Python child so the existing set survives migration. We tried the equivalent and it's the thing that broke: a spawn costs the same ~90 ms whether the caller is bash or TypeScript, so the wrapper buys types without touching the arithmetic.
What worked was inverting it — the Python becomes a long-lived local service and the hook becomes a request to it. Language preserved, spawn cost gone, and imports/DB handles/caches stay warm across calls. If
$can reach localhost, every existing Python and shell hook ports without a rewrite. That's a smaller ask than$.processand it doesn't re-admit arbitrary execution.It also answers your #4: a resident service sees the session id in the payload, so per-session marker files stop being necessary.
3. §2.2's "modifying placement" already ships — and I can confirm @poteat's caching claim from the wire.
That behaviour is live today, via
hookSpecificOutput.updatedInput+additionalContextonPreToolUse. We run a guard that catchespytest … | tail -5— where the pipeline's exit code istail's, so a failing suite reports success — and rewrites the command to append; exit "${PIPESTATUS[0]}".We proxy and archive our own upstream traffic, so I checked what actually goes on the wire:
The rewrite affects only what executes; the model's own block is untouched; the explanation rides alongside. Exactly the semantics described — already implemented, and the prefix stays cache-stable. Validated over our corpus: 66,627 piped invocations replayed, 7,618 rewrites applied, idempotent, output byte-identical.
So §2.2 adds composition and typing to an existing capability rather than introducing one. Worth saying in the doc, because several people here are asking for it as new.
The undocumented edge:
updatedInputis a replacement, not a patch. Returning{"command": …}alone drops every sibling field — it silently discardedtimeout: 600000twice for us and both suites were SIGTERMed at the 2-minute default, with nothing pointing at the hook. The middleware form is spellednext({...e, content}), so the spread is there; if a hook may ever return a partial event, please make a dropped field loud.4. In-process is necessary but not sufficient, because the state isn't in the process.
We run CC in Windows and WSL simultaneously against shared state — a WSL session's tab title, its SQLite rows and half its task directory live on the Windows side. Our two servers peer with each other and forward per-event, with singleton state resolving owner-direct or via the peer (~8,800 cross-realm forwards today). Function hooks are session-scoped and realm-local, so this becomes
$.httpback out to a daemon. For installations at this scale the daemon doesn't disappear — it stops being the dispatcher, which is the win. Naming that in the doc would set expectations correctly.5. Sibling, not successor — @DareDev256 said it best and our inventory agrees: essentially none of our surface ports under a strict "
$is the only door". One sentence committing command hooks to first-class support indefinitely would settle it.@gbrussich52 Indeed,
plugin.registeris a core concept; the side effect static analysis data ought be included so org admins can write plugins against it to decide what plugins get registered in the first place. Very, very good call.@deafsquad yep, keeping it all in-process on Bun is extraordinarily fast. we're looking at a p99 of 50μs per hook tbh. @Jarred-Sumner has done amazing work here.
This would be genuinely useful. I run Claude Code heavily for deploy/ops automation (git push → build → pm2 restart flows, WhatsApp/IG customer-support bots, subagent-driven pipelines) and today the only way to react to lifecycle events is shell hooks — no way to inspect or transform the actual data mid-flight, and definitely no way to touch how something renders.
A few things I'd want to see nailed down before this ships:
Perf/safety story when several hooks stack on a hot path (e.g. every tool call) — middleware chains have a way of quietly adding latency.
Versioning/back-compat for the $ API surface, since plugins will start depending on it immediately.
Clear default: does an unhandled hook error fail open or closed?
Overall: +1, would use this day one for admin-level guardrails and audit logging alone.
@poteat 50μs p99 is a decisive number — that's ~1,800× a subprocess spawn, and it retires framework overhead as a design concern entirely. Worth putting in the doc; it's a stronger argument than any of the demos.
One distinction I'd guard against, since it's the natural misreading: 50μs is dispatch, and for heavy installs the cost is work, not dispatch. Our 261 ms batch is two hooks doing real things — a transcript scan and a state-DB read — that cost the same in any runtime. So the number makes the framework free without making the hooks free, and the open question for existing surfaces stays the one @DareDev256 and I both landed on: whether that work can stay out-of-process and in its current language, or has to move.
Not a criticism of the figure — it's excellent. Just that "50μs per hook" and "hooks are now free" will get conflated, and the second one sets people up to be surprised.
+1 strong yes for me too.
I was looking at the bb IDE the other day and how it allows you to "vibe-code" extensions for it (without having to fork the repo - modify code - push a PR).
if I understood correctly this would work also on other surfaces (eg Desktop... and mobile?)? would it work in cowork/chat too? (do these harnesses have "hooks"?
💡 I see many possible ways to fix and improve the UI/UX. some random ideas I wrote - mostly for non-code outputs:
ect
if any of those are possible with function hooks, I think you would receive a lot of feedback asking designers and devs for their UX frustrations :)
great stuff, looking forward to testing it
Following up with something that might change the scoping, offered as a map rather than a correction: we use nearly every part of the current hook API in anger, and a good deal of what this thread is asking for already works today. Worth knowing before the feature is scoped, and worth documenting either way.
Fields the shipped API already hands every hook, by count of places we read them:
Against the asks in this thread:
| ask | available today? |
|---|---|
| structured
e.tool/e.argsinstead of regex — @AdityaRon | yes —tool_name+tool_inputas a dict || modifying placement, §2.2 — @Helban | yes —
hookSpecificOutput.updatedInput|| "this path, this shape of edit, nothing else" — @ahjin2 | yes — PreToolUse +
tool_input.file_path+permissionDecision|| guards that survive delegation — @ahjin2 | mostly —
agent_typeis in the payload || session identity from inside the process — @heatherhelen | yes —
session_id, on every event || wildcard audit log — @heatherhelen, @alhe99 | yes, unergonomically — matcher
*per event, not oneon('*')|| composition /
next()— @DareDev256 | yes, if your hooks share a process (below) || typed returns | no, but see the note at the end |
| capability removal from
$| no ||
ui.render/ui.press| no |On composition specifically, since it's the one I'd least expect people to believe.
next()is unavailable from the shipped dispatcher — hooks return to it and it merges by fixed policy (updatedInputfirst-wins, block-wins, context concatenated), so no hook ever sees another's return. But that's a property of separate processes, not of hooks. Once your hooks live in one resident process they are just functions, and you can fold them however you like.Ours does. Our Stop handling is five formerly-separate hooks merged into one ordered pipeline where gate 1 short-circuits everything beneath it —
A(B(C(⊥))), earlier wraps later, exactly §2.1 — implemented in Python because the runtime doesn't offer it. 60 commits of maintenance on it since February. So the middleware fold is not gated on this proposal; it is gated on your hooks sharing an address space. Two independent installations arriving at the same shape unprompted is, I think, the strongest argument for putting it in the runtime — but nobody has to wait.A correction to my own table, because it matters to @AdityaRon. Structured args will not close your bypasses.
tool_input.commandfor Bash is a string, because a shell command is a string —git -c, obfuscated payloads and the rest live inside it, and{ tool: "Bash" }matching doesn't reach them. Function hooks won't fix that either. The thing that would is capability withholding at the network/process noun (§4.2), which is a different mechanism than the one you're asking for.So the genuinely missing set is small: capability removal,
ui.*, and ergonomics. That is not an argument against shipping — capability removal is the item @ahjin2 correctly identifies as structurally impossible in shell hooks, and I'd ship it for that alone. It is an argument for reading the demand signal carefully: six of the ten rows above are discoverability, and a new runtime doesn't fix a documentation gap, it inherits it.On typed returns, having now argued myself out of my own position: they don't create the distinction that matters. We shipped a bug last week where two guards both returned
{}meaning opposite things — "not mine, refusing" and "mine, delivering" — and the event reached no one while both logged success. Nothing stopped us returning a discriminated value; we simply hadn't designed one. A type would have made that omission a compile error rather than a silent convention gap. Real, but it is authoring discipline, not capability — worth calibrating against the ergonomics claims elsewhere in the thread.And a reframe of my own earlier ask, which I put badly. The constraint isn't language — TypeScript is fine. It's shimless reach: whatever a hook is written in, it must be able to call existing logic directly, with no translation layer between.
$.httpto localhost satisfies that completely and is a much smaller ask than$.process, which re-admits arbitrary execution and costs the ~90 ms spawn I measured earlier. If$can reach a local socket, every existing hook set in this thread ports on day one without a rewrite.Strong yes. 53 hook registrations across 11 events, 47 distinct scripts, single operator, on a surface I cannot find mentioned anywhere else in this thread, the VS Code extension. @deafsquad has the Windows spawn arithmetic covered and covered better than I could, so I will not repeat it.
Almost every failure story above is a script failing quietly. Mine are the host failing quietly, which no amount of shell discipline reaches, and I think it is the stronger argument for a typed in-process contract.
1. An event that exists, is documented, and never fires on my surface
I wired my permission alerts to
Notificationfirst. It never fired once. Not slow, not misconfigured, just silent. The working event isPermissionRequest. My hook was correct, my JSON was correct, and the surface I was on simply does not deliver that event.That is @DolphusCY's eight week dead hook with the cause moved one layer down, and it is worse in one way. No amount of reading my own code finds it, because my code is fine.
So the ask is smaller than it sounds. If a registration names an event this build or this surface will never deliver, say so at registration time.
register(on)makes that answerable, which is @yonatangross's point, and I would extend it. Enumerable registration is worth as much for catching a registration that can never fire as for catching one that drifted out of a dispatch map.2. The output ceilings are two, not one, and one of them is undocumented
@Helban measured output above roughly 8 KB vanishing with no error, and split one memory file into thirteen because of it. Same problem here, and I went and read the constants out of the shipped binary. From
resources/native-binary/claude.exein the 2.1.258 VS Code extension:There is a line cap as well as a character cap, and I had no idea it existed.
additionalContextis 200 lines.reasonandstopReasonare 20 lines each. A Stop hook explaining why it blocked gets twenty lines, and past that the explanation is cut.My
SessionStartloader was emitting 8,124 characters against the 8,000 cap. The last 124 were dropped mid word, severing the final term of the last rule and the measurement that made that rule land. Nothing raised. The truncation sets a flag the hook author never sees.That matters for this proposal in a specific way. Several people here, myself included, have built machinery whose only purpose is staying under a ceiling we discovered by accident. If a function hook returns a value rather than writing to stdout, please make crossing a limit throw rather than truncate, and if it must truncate, put the fact on the return value where the author can read it. @DolphusCY's caution applies exactly, because a revoked or exceeded thing that comes back benign is
2>/dev/nullwith better types.3. A timeout that reports the fact but never the measurement
One of my
PreCompacthooks started dying. The harness saidHook cancelled, which is honest and is the host doing its job. It took three wrong diagnoses across two days to find out why, and one of the three was confidently wrong in a way somebody could have acted on, because I sent it to another operator as a finding.The reason is that
Hook cancelledcarries no measurement. Not the elapsed time, not the budget it blew, not which phase. Diagnosis was archaeology, stubbing modules out one at a time and bisecting. The real cause was a helper that reads a file, cheap when a guard calls it once per turn and ruinous when a transcript walker calls it once per record. The cost of a function turned out to be a property of its caller, and nothing in the pipeline could show me that.Numbers, since the shape is the point:
This is the strongest case I have for
$side effect tracking. If the host recorded that a hook made N$.fs.readcalls, that regression is visible on the first run instead of after the third wrong theory. Typed failure is good. Typed cost is what I actually could not get.Related, and it answers @DareDev256's second question for the current pipeline rather than the proposed one. Today's timeout is per hook, not per group. The binary carries the string verbatim:
and the timeout defaults differ by hook type,
e.timeout?e.timeout*1000:30000for prompt hooks and:60000for agent ones. So one bad hook does not currently wedge the others. Worth having in the thread, because the same guarantee needs restating for a middleware chain where it is genuinely at risk.4. The design point I have not seen raised: a watcher fires on being discussed
This one is not about failure modes, it is about what you key a hook on, and it has cost me more than everything above combined.
**Every content matching guard I have written fires when the conversation is merely about the thing it watches.** A guard that blocks a dangerous command shape fires when I ask what that shape is. A guard that catches a forbidden phrase fires on the sentence explaining the rule. A guard watching for a deletion fires on a paragraph about deletions.
Six separate guards here discovered that independently, and none of them wrote the rule down. Each author treated it as a quirk of their own matcher rather than a property of the category, so the seventh author got to find it again.
The defences that work are all structural, and structured events make two of them free:
on("tool.call", { tool: "Bash" })over structured args cannot fire on a sentence, which is @AdityaRon's and @puretensor's bypass point arriving at the same place from the other direction. It closes the regex bypass class and the false positive class, and only the first half has been argued here.So a request: when the docs land, please write down that content matchers fire on discussion. It is obvious the moment somebody says it and it is invisible before that, and six independent rediscoveries inside one install suggests the number outside is not small.
5. One number on placement, since prose keeps coming up
@ahjin2 wrote that prohibitions expressed as prose hold most of the time and fail exactly when the stakes are highest. I have that measured. One of my rules lived as a written instruction, loaded into every session, for weeks. It lost 389 times against 57. The identical rule as a blocking
Stophook works, and it still fires dozens of times a day, which is the point rather than an embarrassment. A guard is a net, not a teacher.The consequence for this design: the value is in the placement, not the notification. A hook that observes and warns gets tuned out by the thing it is warning, whether that thing is a person or a model.
modifyingand deny placements are where the feature earns itself, and I would rather have those on day one thanui.render.The one I would trade everything else for
Sibling, not successor, as @DareDev256 and @deafsquad both put it. 49 of my 53 registrations launch a Python interpreter through
pythonwspecifically so a console window does not flash on screen every turn, which is a Windows detail that says something general. A lot of this surface is shaped by the host rather than by the logic, and that part does not port. @deafsquad's inverted answer, a resident local service reachable over localhost, is the migration path that preserves the existing set, and it is a much smaller ask than$.process.Happy to share the full inventory, the surrounding byte strings for the caps above, or the six watcher cases if any of it is useful for pressure testing.
YES! What I'm hoping to see on
$, in priority order:session.focus/session.blur, carryinge.surface. The Desktop window title is static ("Claude"), so nothing outside Claude Code can tell which conversation is active. One event closes that.session.title, fired on the auto-summary and on a manual rename (with the source), ande.session.titleon every event. Today the only way to get it is scraping the Desktop session-store JSON on disk.terminalnoun doesn't already expose it,$.terminal.titleor a hook on the event that sets it. A session token in the title gives external tools an exact join key in any terminal.$.schedule(interval or at-time, scoped to the session). Plugins can't run timers, so periodic work ends up in launchd.e.workspace— cwd, project root, worktree parent, branch — supplied by the engine, VCS-agnostic, sincegitwon't be on$. That removes agit rev-parseshell-out and a class of deleted-worktree misattribution bugs.continuedFrom. One Desktop conversation spanning several transcripts currently looks like several sessions.And seconding the
hooks.jsonforward-compat point: an unknown key shouldn't drop the whole file.@pablogranate
Great question. If nobody in the chain catches the error and it bubbles up to top, the engine just dispatches again and goes with its default behavior, as if no plugins were registered at all. Our interest is in avoiding breaking the harness. It's a subtle trade-off for sure; to some degree hackability begets breakability. This is very up in the air. We might also just have the engine just skip you if you throw an error.
@lyricalpolymath _my_ personal goal is for this to work on any surface powered by the Claude Code binary. Our internal prototype supports local Claude Code Desktop at the moment, as demonstrated in a few of the videos. other surfaces are top of mind but tbd.
Pretty cool! Can't wait to test it out
@poteat on the error model, since it's still open — I think fail-open as stated conflicts with §4.2, which is the part of this proposal I'd most want to survive.
Video ⓸ sells capability withholding as mechanical: an admin removes an affordance from
$and nothing registered below can invoke it. @ahjin2's endorsement rests entirely on that — "prohibitions expressed as prose hold most of the time and fail exactly when the stakes are highest. Mechanical constraints hold."An admin hook registered first wraps everything beneath it. If it throws, and the engine proceeds as if no plugins were registered, then the withheld affordance is back and the call goes through. Capability removal becomes exactly as strong as the admin hook's exception handling — which converts the mechanical guarantee back into a best-effort one, the property §4.2 exists to replace. That isn't a preference about security posture; it's an inconsistency between two parts of the same design, and worth settling while it's still up in the air.
I also think open-vs-closed is the wrong axis. Protecting the harness is the right instinct — fail-closed genuinely does brick sessions, and hackability does beget breakability. The question that actually matters is whether an operator can distinguish "the guard ran and allowed" from "the guard died and was skipped." Fail-open loudly is defensible. "As if no plugins were registered" is fail-open silently, and silence is the part that bites.
Concretely, from last week: we had two guards on the same event, one meaning "not mine, declining" and one meaning "mine, delivering." Both expressed that as
{}. Both logged success. The event they were arbitrating reached no session at all and sat until a 30-minute TTL expired it. No error, no exception, two correct-looking log lines, and the only reason it was found is that a human noticed something missing.That was a designed return value being ambiguous. If an exception path also produces nothing distinguishable, then "no guard registered" and "guard threw" become the same observable state — and at that point the audit log cannot answer the one question you'd build it for.
It also collapses @ahjin2's other open question into this one. "How do I test a hook?" and "what happens when a hook throws?" are the same defect seen twice: if failure is unobservable, you can't test it or catch it in production.
So the narrow ask, rather than a complaint:
*hook sees it, and it surfaces to the operator. Fail-open loudly costs nothing and removes the whole silent class.Point 3 is the one I'd argue hardest for. It's a sentence in the doc, and without it §4.2's guarantee is only as good as everyone else's
try/catch.Yes. Skipping the parts already covered upthread (inventory shape,
$reach, silent failure) and adding one structural thing I have not seen named.Three questions in this thread are the same question. @yonatangross asks what the analog of
additionalContextis and notes the doc names no event that carries it. @ipoltorak asks whether there is a surface for the periodic case, sinceon('schedule', ...)does not exist. @alhe99 screen-scrapes tmux panes because there is no event for a session hitting a usage wall.All three fall out of one fact: §4 defines an event as a method call on
$, and a lifecycle moment is not a method call.$.prompt.submitexists because the REPL calls it. Nothing calls$.session.start.That is 56 of my 100 hook registrations with no address in the model:
| event | registrations | candidate noun |
|---|---|---|
| SessionStart | 28 | none in the doc |
| Stop / StopFailure | 19 | none |
| PreCompact / PostCompact | 3 | none |
| SubagentStart / SubagentStop | 3 | none |
| PreModelSwitch / PostModelSwitch | 2 | none |
| SessionEnd | 1 | none |
So for me the question is not whether these can migrate. It is whether they have a home at all.
And that decides @yonatangross's question 2, the one marked "tbd but top of mind". If lifecycle events keep no noun, command and function hooks interleaving is not a migration-period concern that resolves once everyone ports. It is the permanent steady state for every install that has a session hook, which is all of them. Worth settling on that basis rather than as a transition detail.
One measurement on the output cap failing silently (@yourstrulyeden's second point): we hit it from the other side in August. When a
SessionStarthook'sadditionalContextwent over the limit, the harness did not just truncate it — it wrote the full text to a file on disk and handed the session the first 2,000 characters as raw, unparsed text, escape sequences and all. Nothing reported it. So the failure is not "the tail is missing", it is "the whole payload changed shape", and a truncated payload read like a complete one for days. We now inject a pointer to a file and never a document, which is the same conclusion the thread reaches; a return value that throws on a crossed limit would have found it on the first run. Worth noting that the caps differ by field and that the raw-fallback path exists, since a hook author testing under the limit never sees either._Posted by Claude Fable 5.1 agent on behalf of this account's owner._
Strong yes, with two specifics I care about and two cautions.
Context for weighting: I am not a developer. I run a marketing agency almost entirely through Claude Code, currently 55 hook scripts across a federation of pipelines. So I am a heavy hook user rather than a heavy TypeScript user, and I am arguing from what breaks in practice.
1. Capability subtraction on
$is the best idea here.Today a shell hook fails open and silent. If it errors, exits with the wrong code, or was never wired into the right settings file, the action just proceeds and nothing tells you. I have a SessionStart check whose only job is to prove my other guards are still connected, which is a workaround for a structural problem. Removing an affordance from
$so nothing below can invoke it is fail-closed by construction. That is a categorically better safety story than any number of hooks that say no, and it is the reason to build this.2. Typed payloads would kill a whole bug class.
My hooks read JSON off stdin and guess at field names. When a shape changes they do not fail loudly, they quietly stop matching. Real type definitions fix that.
3. Caution: component and render hooks need their own affordance.
"Plugins can draw now" is the most dangerous item on the list, not the most exciting. A plugin that can rewrite what I see on screen can misreport what it did. Please make rendering a distinct capability on
$that admins can remove, never something a plugin inherits by being loaded.4. Caution: I would be trading away process isolation.
A shell hook runs in its own process, so a badly written one cannot hang or crash my session. An in-process function can. If this ships, it needs per-hook timeouts, crash isolation, and an error surface that names which hook failed. Right now a broken hook is survivable, and I would not want that to stop being true.
5. Please keep shell hooks first-class.
Not deprecated, not second tier. Fifty-five Python scripts are not going to be rewritten, and a lot of the people getting the most out of hooks are not TypeScript developers.
Whatever the mechanism, the single highest-value thing you could add is a loud check that a hook is actually wired. Silent non-wiring has been my most common real-world failure by a wide margin.
@deafsquad I find that very convincing. From what I've read in this thread so far, I'm now very much leaning towards catching all errors, logging them, and routing around that hook (i.e. passing the value the below
nextreturned and making it the return value of the abovenext).I am thinking
nextwould only throw (i.e. promise-reject) in three cases:e, or you tried to do something weird like callnextafter you had already returned.nextWhat do you think of also running runtime type checks, i.e. forcing
nextprops and return values to have a certain structure at runtime, even if you useanyetc (as long as we can prove it remains performant)? I think these two aspects could go a long way for preventing hard-to-debug scenarios.I built a working prototype of the algebra from the architecture doc: https://github.com/Monte9/claude-function-hooks —
npm testruns 17 tests, each citing the § it checks. Not affiliated with Anthropic, and any mistake in it is mine, not the proposal's.tl;dr on the delta. The real change is one line in today's docs — "All matching hooks run in parallel" — replaced by an ordered fold where order is authority. That, plus in-process types and
ui.render, is the contribution, and it's a good one. Worth naming because §1's motivation compares against a simplified version of what exists: today there are 33 events, andPreToolUsealready denies, rewrites input viaupdatedInput, and injectsadditionalContext. The proposal is stronger pitched on composition than on capability.Feedback or observations
Two things fell out of implementing it that I don't think are visible from the doc alone.
1. Every read of
$is async, and the spelling that fails is the one TypeScript misses.Since every method on
$is an event, every read is a dispatch returning a promise.tsc --strictrejects$.perm.allowed(…).value. It says nothing at all about:A promise is always truthy. The spelling the compiler catches fails closed; the one it stays silent on fails open — in the system whose headline is safety. Might be worth a lint rule, or a
$surface shaped so the sync-looking read can't typecheck. (npm run finding:awaitprints both cases and the verdict.)2. ~85–90% of the per-dispatch floor is the
AbortController, and it's cheap to avoid.Measured on the prototype: ~5.6 µs per
$call with zero hooks, and going 0 → 25 hooks adds only ~5 µs. So the cost is the dispatch, not the chain — which matters for a design where reading config is also a dispatch. Ablation puts ~5 µs of that floor on constructing the controller §3.1 mandates "one per dispatch".Constructing it lazily from a getter defined once on the
nextprototype preserves the semantics — the signal still fires for a hook that subscribes — for ~6–7× (6.2–7.7× over six runs):A lazy getter defined per-
nextdoes not work: it just trades the controller for a per-dispatchdefineProperty. Node numbers from a prototype rather than from Claude Code, so the ablation and the shape are the transferable parts, not the absolute figures. (npm run finding:cost,:ablate,:fix.)Three smaller findings are in the repo, including an ambiguity in §6.4 where one reading of "own frame" defeats the benefit §6.4 claims. There's also a runnable answer to the question @yuzushi-dev posted about redacting PII before it reaches the model (
npm run demo:redact). Hope this helps!A retraction first. My "~1.9 s on every tool call" was arithmetic, not a measurement — 21 hooks × ~90 ms. The hooks reference says hooks run in parallel, so the real floor is max(spawn) plus contention, a few hundred ms. My own batch number already proved it: two hooks at 163 and 207 ms inside a 261 ms batch can only be overlapping. The spawn-cost argument stands at roughly a tenth of what I posted. Sorry for the noise.
It also surfaced something I'd missed, which I think matters more.
Middleware serializes what is parallel today. In the fold, A must
await next()to see B's result, so 21 independent hooks become one chain. Our two heavy hooks go frommax(163, 207) = 207 msto370 ms. Anything that vetoes or rewrites has to sit in that chain — "during" can float, but by construction it can't decide. So for a heavy install, function hooks can be slower end-to-end than warm parallel command hooks, and the 50 μs dispatch is real and irrelevant to that. The known shape is Kubernetes admission control: mutating webhooks serial and ordered, validating webhooks parallel. Interceptors and observers as two kinds, not one.Which reframes the resident-server thing I described earlier: it isn't your model made warm, it's the shell model made warm — parallel, independent, non-composing. It proves the performance win doesn't require the algebra. The algebra then has to justify itself on composition, which is the honest case for it: two parallel command hooks that both return
updatedInputhave no defined winner today, and the fold fixes that. That's the real advantage, and it's worth claiming directly.---
@poteat on your question. Yes to runtime checks — but I had the coupling backwards, so let me put it right. Under route-around the engine already knows a hook threw; it caught it. What's lost is the chain's knowledge, and that's a channel problem, not a validation one. Runtime checks catch a third thing: a hook that ran and returned garbage. Those are needed anyway, and my
{}bug is exactly why — no type check would have caught it.{}was a valid shape with two meanings. Same forundefinedfrom a forgottenreturn next(e), the number-one Koa bug class. So:undefinedis always an error. The check is a property read.eis immutable and rewrites are copies, soreceived === returnedmeans skip. Zero cost for the 19 of 21 hooks that pass through. That's the space-efficient-contracts lesson (Herman/Tomb/Flanagan): a value crossing N boundaries must not be checked N times. It also settles the performance question — our bodies run ~2.7 MB across ~1,300 messages, and deep validation per hook would be brutal, but on-change tag checks are nanoseconds.next.originexists; extend it to a per-dispatch trace.next.skippedlist. The doc now has three silent skips — matcher miss, the §6.4 recursion skip ("skips it silently"), and route-around. The*audit hook's claim "what a plugin did is exactly the calls it made" is false if it omits the hooks that didn't run.{value, skipped:[{id, reason, error}]}with reasons{matcher, recursion, error, timeout}makes it complete, and gives the admin hook what it needs to turn a skip into a deny. Fail-open vs fail-closed then stops being an engine constant and becomes an admin decision at the top of the chain — which is §5's own thesis, "enforcement is plugin order," applied to errors.On your case 2: ⊥ is "an immediately-throwing function" (§6.2), so "everything below threw" is indistinguishable from "the core implementation threw." A hook can't know whether it's bottom, so whether
nextthrows depends on the count and health of hooks below it — positional nondeterminism. I'd make ⊥ a total function returning the engine default, and give "no implementation" its own error type.---
Retargeting my earlier point on §4.2, now that I've read §4.1. Noun-withholding isn't the exposed path:
$is built once at startup by theengine.createfold and every step returns a value, so a throw or timeout there can't restore anything at event time. The exposed path is method restriction, because the doc forbids replacing a noun in the fold — "A step adds nouns and may withhold them; it does not replace one" — so "denyfs.readoutside the repo" is forced into a per-event hook on the hot path. Three things close that:failurePolicy: Fail | Ignoreand timeout, set by the registrant. Kubernetes has this for the same reason: uniform fail-open and uniform fail-closed are both wrong, and the registrant knows which applies. Route-around becomes the default for hooks that don't say.engine.createis never routed around. An admin step that throws at startup means the engine doesn't start. One line, and the only exposure of noun-withholding is closed.on("tool.call", {tool:"Bash", command:/rm -rf/}, DENY)with regex matchers. Nothing to throw or time out. It's also the honest answer to enterprise admins, for whom "an allowlist is a plugin you write in TypeScript" is a step backwards from today's JSON.The ocap-native alternative — attenuating by wrapping the capability at fold time, E's caretaker pattern, so the check lives inside the callee's call and has no hook control flow to route around — is exactly what the "does not replace" rule forbids. That uniformity is what puts admin restrictions in the throwable path. Worth reconsidering for the narrow case of replace-with-narrower.
---
Three things I don't think anyone has raised, from the PDF rather than the videos.
The isolation is a linter. "A module that manipulates
$any other way failsclaude plugin validate." That's the entire mechanism behind "no ambient filesystem or network" and "trustworthy by construction." In the same isolate, static validation cannot withholdfetch,require('node:fs'),Bun.spawn,globalThis['fe'+'tch'], orFunction(...). Withholding$.httpstops hooks that politely use$.http— the population that didn't need the guarantee. And$frozen is shallow unless hardened; a plugin can otherwise reassign$.fs.readand change what every other plugin's calls do. The only known way to get no-ambient-authority in-process is SES / Hardened JS (lockdown()+ a Compartment with$as the sole endowment); Agoric runs it in production, per-call cost is ~zero, so it's compatible with the 50 μs. Which of SES, a Worker with a restricted loader, or a lint is intended? Each gives §4.2 and §5 a different truth value. Today's shell hooks, whatever their faults, are process-isolated.Re-entrancy guard vs parallel tool calls. §6.4: "A hook is never re-entered while its own frame is being dispatched: the engine skips it silently." Claude Code issues parallel tool calls. If "frame" is a per-hook in-flight flag rather than a per-dispatch lineage, then an admin guard awaiting inside tool call #1 is silently skipped for tool call #2 arriving meanwhile — a concurrency bypass of the top-of-chain guard, and "silently" is the doc's word. This is an implementation question, so I'm posing it as one, but if the answer is the flag it's the most severe thing in the design.
Timeouts invert authority under nesting. For an "after" placement, a hook's wall time includes everything below it, so the outermost hook — the admin — always has the longest wall time and is the first a naive per-hook timeout kills. Timeout has to be self-time excluding time inside
next(), which means the engine time-slices around eachnextcall. Defined any other way, case 3 undoes the authority order the same way route-around did.Minor: §6.4's "~linear" — k
*hooks that each make one$call per event is roughly k + k·f(k−1), so 1, 4, 15, 64, 325… bounded and terminating, but not linear. "Linear in hooks that don't themselves call$" is the accurate claim.The shape is good and well-trodden — Koa's fold, Emacs
:aroundadvice, AspectJ with the "props are public API" rule addressing fragile pointcuts. The cautionary tale is Atom → VS Code: Atom let packages wrap the core in-process and lost attribution and performance accountability; VS Code moved to an extension host with an explicit API and declarative contributions. This is Atom with discipline, every wrap explicit and ordered — and that discipline holds only if the isolation is real rather than a lint, and failures and skips travel up the chain. Without those two, "enforcement is plugin order" is a slogan.One more, and it's structural rather than about errors: the algebra has no collapse.
I searched the architecture doc for any primitive that reconciles more than one result — reduce, merge, combine, aggregate, a "wins" rule. There isn't one. "Fold" appears only in §4.1, building
$once at startup. The entire rule for what comes out of a chain is one sentence:So the onion threads one value. Each layer sees only what came back from directly below it, and by the time the outer layer sees anything, every inner verdict has already been reduced to a single return. If hooks 2 and 5 both object for different reasons, the chain surfaces one — whichever return survived — and the other is gone. That isn't a bug; it's what a Koa chain is. But it means the serialization point from earlier isn't only a performance cost. It's lossy. Every layer boundary discards the layers beneath it.
I'll offer our history because it's the same road, and we're a few steps further along it.
Our resident server started exactly where this proposal starts — "what if we combine all hooks per type in a single call," five spawns to one, 235 ms to 72 ms. But combining N hooks into one call forces the question how do N results become one, and the merge rules got written in the same reply as the speed win. Six months later the speed is the part nobody thinks about, and the merge is the part doing the work. When we finally opened it up, we found five different combination rules scattered through a 150-line loop, four of them order-sensitive, and since execution order was array position in a JSON file, array position had become a semantic nobody had written down. Two of the rules were asymmetric —
updatedInputtook the first contributor while the deny reason took the last — and nobody had chosen that; it was simply what the loop happened to do. The live consequence: when two guards both refused, the operator saw whichever reason sat lower in the file and the other was silently lost.The fix was to declare the reducer per field, so the asymmetries sit side by side where a reviewer can see them:
Two properties fall out of that which the onion can't have:
Every verdict survives to the fold. Our Stop handling is seven gates, first veto wins, short-circuit — the §2.1 shape. The short-circuit maps cleanly to an outer hook declining to call
next(). But the result carries a composed reason, "blocked by gate 2 (untested edits) and gate 4 (pending operator items)," because the fold sees all seven before deciding. In the onion, gate 2 receives gate 4's objection already flattened to a return value. A veto decides whether; only a fold can decide how completely it explains itself.Order-dependence is derived, not discovered.
MERGE_ORDER_SENSITIVEis computed from the table, so we know exactly which fields a registration reorder can change, and a golden-master test pins them (it caught a variable-shadow bug in the refactor itself before it shipped). In the onion every field is order-sensitive and nothing declares it — the "which advice changed this?" pathology Emacs took decades to bolt introspection onto.This isn't idiosyncratic. It's where the plugin systems that lived long enough all converged, and we read them before building: pluggy declares result combination on the hookspec — the event — not on the implementations (
@hookspec(firstresult=True); default collects a list). WordPress and Drupal encode it in the kind — actions are side-effect-only and order-insensitive by construction, filters fold a value and are order-sensitive by construction, and you know which you're writing from the name. Kong shipped static numeric priority, hit the wall, and added declarativebefore/afterin 3.0. Drupal 11, rewriting its whole hook system, considered numeric weight and rejected it forOrderBefore/OrderAfter. The shared principle: order-sensitivity belongs in the declaration, not in whoever reads the merge loop. And it's rare — in 54 registrations across 14 events we have exactly one declared ordering constraint. Everything else is a fold that doesn't care.Which is the interceptor/observer gap seen from the semantics side. Interceptors — serial, transforming, one value through — are what the onion is. Observers — many, parallel, each producing a verdict — have no home, and their verdicts have nowhere to combine even if they did. The doc's own strongest examples, the
*audit hook and the admin guard, are observers in interceptor clothing. The one concurrent placement, "during," floatsnext(e)and by construction can't veto or modify: fire-and-forget, not fold.It also explains why
{value, skipped:[{id, reason}]}kept coming up above from different directions: it's a partial reification of what a fold has natively — reconstructing a collapse after the chain threw it away.The ask is small and orthogonal to everything else here: give observers a real primitive with a declared reducer — parallel handlers on an event, and a per-field combinator (veto / first / concat / accumulate / custom) over what they return, declared on the event the way pluggy does it. Interceptors keep the onion. Observers keep their verdicts. And the admin at the top sees all of them, which is what "enforcement is plugin order" was supposed to mean.
Yes please. These look FANTASTIC.
I vote yes
Strong yes from a heavy hook user — three specific gaps this would close for us.
Our setup: a local "brain" service (SQLite + vector recall) that a
UserPromptSubmithook queries every turn to inject memory / ambient state into the prompt, plus tool-call guards. Everything is bash + JSON over stdout today.1. Typed returns instead of stdout markers. Our hooks return receipts as text lines the harness/next-turn parses (
[tag]: ...). One stray backtick at the start of a line and the whole night's receipts silently become "no marker" — the hook "ran fine" by exit code and did nothing useful. A typed return value makes "ran and produced X" vs "ran and produced nothing" representable, which is exactly the DareDev256 point above; we ended up writing a watchdog that greps our own hook output too.2. Structured
e.tool/e.argsfor the "read-looking commands that write" class. We lost 41 launchd plists in one afternoon toplutil -extract KEY xml1 <file>without-o -(it rewrites the file in place), and a second batch tosed -i. Today the only defense is a blacklist pasted into every subagent prompt, i.e. "remember to copy the red lines." Atool.callhook matching{ tool: "Bash" }with parsed args would let us veto that whole class once, in code, instead of relying on prompt discipline.3. Render hooks to get ambient state out of the prompt. A lot of what we inject (heartbeat/status/“what changed since last turn”) is for the human, not the model. Being able to draw it in the desktop/terminal UI instead of stuffing it into the context would cut token cost and protect the model's attention — we just spent a week slimming that hook for exactly this reason.
One ask for the design: a hook that can observe (or budget) the token size of what all hooks together injected this turn. Context bloat from well-meaning hooks is the failure mode we hit most, and today nothing at the hook layer can see the aggregate.
Adding two failure instances from a heavy hook install, both bearing on the error-semantics question that is still open above.
Scale for context: 71 hook registrations across 15 events, 32 of them on PreToolUse.
1. The host can discard the channel a hook is writing on, and the hook's own test suite will certify it works.
An advisory hook that exits 0 must deliver its text as stdout JSON, because stderr is not delivered on a successful PreToolUse hook. Only the exit-2 block path reads stderr. The two paths use opposite channels, so the wrong choice fails silently in opposite directions.
We hit both. One guard detected its target correctly, wrote a full warning to stderr, returned 0, and reached nobody, while its own 19-check contract suite asserted stderr was non-empty and reported PASS. A guard against silent failure, failing silently, with a test certifying it worked.
The mirror case was worse. A live registered gate had been echoing its entire block message to stdout since it shipped, so every block anyone ever hit arrived with no stated reason. It was fail-closed and correct on its exit code the whole time, which is why nothing surfaced it. We found it only because an unrelated probe happened to assert byte counts on both streams.
For this proposal: a typed return value makes both unrepresentable, which is the strongest argument in the thread that has nothing to do with ergonomics. It only helps if the type carries the delivery channel and not just the payload.
2. On fail-open, we have shipped the thing that is still undecided above.
One of our guards is a deny-by-default PreToolUse blocker on credential reads by dispatched subagents. It fails open on error, deliberately. The code says
rc = 0 # fail open. It also fails open on timeout. The consequence is written into our own docs as "no block is not proof it ran."That was a reasonable call under the constraints we had and it is still the wrong property for a security-shaped guard. It is the exact tradeoff being discussed above, already in production, and the reason we would want fail-closed to be declarable per hook rather than a global default. A guard that disappears on error is an advisory wearing a blocker's clothes.
One request in the same spirit as the capability-removal comments: whatever the default becomes, make the effective state observable. Our worst incidents were not guards making the wrong call. They were guards making no call while everything reported healthy.
Follow-up with measurements from our stack. @deafsquad flagged that the spawn data was Windows-only and likely under-reported; here is macOS, and it changes the shape of the argument a little.
1. macOS: spawn is ~20% of our hook cost. Work is the rest. Claude Code 2.1.259, macOS 26.6.2, Apple silicon. Every command hook that fires on one
Bashtool call (settings + enabled plugins, active versions only), 15 runs each, median wall time, with the bare spawn floor measured separately:| hook | runtime | median | of which spawn |
|---|---|---|---|
| numbat (Go, compiles 51 rules per call, writes NDJSON) | native | 113 ms | 5 ms |
| 4 project gates (
scripts/gates/*.py) | python3 | 17–22 ms each | 14 ms each || hookify PreToolUse matcher | python3 | 25 ms | 14 ms |
| ai-plugins enforce | bash | 24 ms | 2 ms |
| remember PostToolUse | bash | 95 ms | 2 ms |
| claude-security PostToolUse | sh | 40 ms | 3 ms |
Floors on this machine:
bash -c :2 ms,sh -c :3 ms, a static Go binary 5 ms,python3 -c pass14 ms. So Windows pays ~90 ms per spawn, macOS pays 2–14 ms, and of our ~540 ms CPU perBashcall about 100 ms is spawn. The Python gates are almost pure spawn (they would collapse to microseconds in-process). numbat and remember are almost pure work and would cost the same behind$.http.That gives @deafsquad's serialization point a number. Today 7 PreToolUse hooks run in parallel: wall ≈ 113 ms. Folded into one chain with the same work: ≈ 245 ms. The 50 µs dispatch only wins if the work moves in-process too, which for a rules engine means compiling once at
registerinstead of once per event. Worth stating in the doc: the migration gain is amortization, not dispatch.2.
claude plugin validatetoday lists zero hooks. Ran it on hookify, which registersPreToolUse,PostToolUse,Stop, andUserPromptSubmitwith no matcher. Output,--json:Nothing about events, matchers, or what the scripts touch. So "validate lists hooked events and side effects" is not an extension of existing output; that data does not exist yet. Suggested shape for
contents, so aplugin.registergate has something stable to match on and so @yourstrulyeden's "this event never fires on this surface" can be answered at validate time rather than in production:{ "hooks": [{ "event": "tool.call", "match": {"tool": "Bash"}, "placement": "instead" }], "effects": ["fs.read", "http:127.0.0.1", "state:machine"] }.3. Two
$surfaces I could not map our stack onto.chain.*sequence rules (secret read → egress, guardrails off → egress, permission denied → runtime bypass). They need a window that outlives one event, and in our case one session, sinceclaude -ploops run headless from launchd. @DolphusCY's SQLite hooks, @deafsquad's state daemon and @Jiao-Joe's brain service are the same shape.$.httpto localhost gives all of us reach, agreed, but validate then reports "http to 127.0.0.1", which hides the property an admin actually cares about: this plugin remembers things across sessions. A plugin-scoped$.statewith a declared lifetime (event|session|machine) makes that a listed effect and a single switch. Not reach, scope.modelas a core primitive; the thread has not touched it beyond @Jiao-Joe's ask to budget injected context. We keep a per-loop model and cost ceiling in a registry (46 loops) and enforce it with a PreToolUse gate on theAgenttool that rejects a subagent launch without an explicit model, plus a launchd wrapper that checks--modelon everyclaude -p. Neither can see the main session's model or the token count of a request. If$.model.callis hookable withmodifyingplacement andecarries model id and an input-token estimate, an admin plugin can pin a model per plugin or session and deny past a budget in one place. For orgs the cost gate matters as much as the security gate, and it belongs on the same door.Footnote for @yourstrulyeden's "watchers fire on being discussed": seventh rediscovery. Our piped-install gate blocked the benchmark command for this comment because the test payload string sat inside a heredoc. Content matched, command didn't. +1 to writing it in the docs.
Happy to run any of this against a preview build.
Can an agentic system prove what happened, distinguish observation from action, preserve identity/provenance, and make its decisions auditable even when multiple agents modify the same process?
I’m not a heavy user of Hook, but I’m very interested in its UI redrawing capabilities; I need to see the output from specific tools in the message stream as soon as it’s available. I look forward to the release of this feature.
The strongest extension I'd suggest is treating hooks not only as middleware/capability boundaries but as a provenance boundary; If * can observe every event: the runtime could optionally emit a canonical event record containing event identity: parent/causal identity. actor/plugin identity; requested effect; capability used; pre-state/post-state hashes. authorization decision: and execution result: This would preserve attribution across nested hooks and make “who changed what: under which authority: with what observable result.” mechanically answerable:
I'd also distinguish observation: proposal; authorization. and execution as separate effect classes. That would prevent a plugin from implicitly gaining authority merely because it can observe or transform an event;
In other words. middleware controls effects. provenance witnesses them. Combining the two gives you an auditable effect graph rather than merely a hook chain.
First, thank you to everyone working on this: the people who wrote and are carrying the proposal, the prototype
(https://github.com/Monte9/claude-function-hooks), and everyone above pushing on it in the open. I think it is a good
piece of work, and I enjoyed spending an evening trying to break it.
I ran a few dozen adversarial scripts against the prototype, and a handful against today's shipped hooks on
2.1.260. Prototype bugs went to Monte9's repo as issues. Below is only the part about the design: places where the
prototype did something surprising and I could not find a sentence in the architecture doc that says which behavior
is intended. In each case I first say why the current behavior is reasonable, because it usually is.
1. When are matchers evaluated? The prototype evaluates them once, against the original event, before the chain
runs. That is the cheap and predictable choice. But a
modifyinghook that turns aWriteinto aBashcall thenskips a
{tool:"Bash"}guard registered below it, and one that turnsBashintoReadstill runs the Bash guardwith
e.tool === "Read"inside. §6.1 defines what a matcher matches, not when. Today'supdatedInputhas the sameproperty, so the fold is the moment to pick: bind at entry and say so, or re-match per step against the event that
step receives. I would take per step. "Order is authority" is weaker if a lower hook can route around a higher one's
matcher.
2.
denyshould be structural, not a convention. In the prototype the engine only knows whethernext()wascalled, which is Koa's virtue and I understand the pull of it. But a hook can call
next(e)and return{deny}: theaction runs and every visible signal says blocked. Falsy denies block but report allowed.
next()twice runs thebottom side effect twice. Suggestion:
nextis a single-use token the engine tracks per hook, as Koa already does,and a truthy
denyafternextwas consumed is an engine error. This also answers the earlier question of whethera
denywins over anext: with a single-use token there is no such state.3. §6.4's "frame" has to be async-scoped and per chain. Building on Monte9's lexical/dynamic finding: a guard
that marks a hook in-frame only inside
next()misses a hook that dispatches before callingnext, misses X→Y→Xcycles across two hooks, and clears when the downstream promise is created rather than when it settles. With
AsyncLocalStorage(or a chain id onnext) the guard can cover the hook's whole run and detect cycles, andnext.origincan come from the stack instead of the caller, which in the prototype lets any hook dispatch ascore.A related case from today's hooks: a hook that spawns
claude -pgets the same hooks run again in the child, with nomarker in the environment to tell it so. Whatever §6.4 becomes, it should say what a frame means across processes.
4. Immutability by freeze, not by convention. §3.1 says events are immutable plain values, and the
validate-on-change idea upthread (
received === returnedmeans skip) depends on it. The prototype relies on authorscopying, and skipping the copy is a fair speed choice for a sketch. But a lower hook mutating
e.commandin placeafter a guard approved
lsranrm -rf /. OneObject.freeze(e)per dispatch boundary makes the sentence in §3.1true.
5.
{}and[], and one shipped-hooks data point. Under §6.1,{}matches everything and[]matchesnothing. Both are correct (empty "all" is true, empty "any" is false) and both are what a computed pattern degrades
to. Worth a
validatewarning. In the same spirit, today'sBash(as a matcher (an invalid regex) matches nothingand says nothing. And one correction to a claim upthread that shipped
updatedInputis first-wins: on 2.1.260, twoallow hooks that both rewrite the command produce neither rewrite. The original
echo helloran, with no warning(one hook alone rewrites fine). Dropping both is a defensible choice when hooks run in parallel and have no order,
but a silent drop is not. Under the fold this question disappears, since order exists. Until then it is worth a
warning line or an
ask.Held because the thread already has them: hang budget and self-time timeouts, "the isolation is a linter". Repro
scripts for everything above are in Monte9/claude-function-hooks#1 through #4 (https://github.com/Monte9/claude-function-hooks/issues).
If someone above already covered one of these and I missed it, point me at it and I will strike it.
Thank you — the onion model answers it cleanly, and refusals surfacing through the return value of
next(e)is exactly what makes the wildcard usable as a control rather than as telemetry.One follow-up, which is the question an assessor asks immediately after those two: what are the durability semantics of the hook producing the evidence?
Specifically, is a hook awaited before the guarded operation proceeds, or is it fire-and-forget? For an audit trail that distinction is write-ahead vs. write-behind. Evidence committed before the side effect survives a crash mid-operation; evidence appended after it does not. If a session dies between
next(e)and the audit hook's own I/O completing, the operation happened and the record does not exist — and that gap is precisely what gets sampled during an assessment.I don't think this has to be solved in the API. Even a documented statement of which one it is would let plugin authors build correctly around it, e.g. a synchronous flush to an append-only sink before returning.
@poteat — five things, in the order I'd reach for them. All of it is what I actually needed and couldn't get from yesterday's eight-week-dead hook.
1. Who. The hook's identity, not "something below you". Yesterday finding the culprit was a grep across 99 scripts. With nesting this gets worse, not better — by the time an error reaches me it may have passed through four
catchblocks that each chose to rethrow.2. What already happened. The
$effects that completed before the throw. This is the one I'd fight for. A hook that wrote 3 of 21 files and then died is not the same as one that died before touching anything, and today they are indistinguishable. If$is tracking side effects anyway, the error is the natural place to hand back the ledger — and it makes "is this safe to retry" answerable instead of a guess.3. Denied ≠ crashed. If a hook below me called
$.fsand the admin had revoked it, that is not an error in my sense of the word — it is policy working correctly, and my correct response is to carry on, not to repair anything. If it arrives as a thrownErrorI have to sniff a message string to tell the two apart, which is the failure mode this whole thread is about. Make it a distinctkindon the error, not anErrorsubclass — subclasses get flattened the moment anything wraps them.4. Retryable, declared not inferred. A hook that died on a bad flag will die identically forever; a dropped network call might not. The thrower knows which; the catcher can only guess.
5. The one that actually bit me: "I ran and found nothing" must never be able to arrive as an error, or as a value indistinguishable from failure. My eight-week bug was exactly this — the probe returned
"", which was also the legitimate "no file matched" value. Success-shaped failure. So the ask onerrhas a mirror on the success path: if a hook completed and produced nothing, that has to be structurally distinct from a hook that never ran, and both distinct from one that was denied. Three states, three shapes, no overloading of empty.Concretely, something like:
To your framing — the reason I'd put
effectsabovecauseis thatcausetells me what went wrong andeffectstells me what state the world is in. When I'm the hook that has to decide whether to continue, only the second one is actionable.One caution on the catch itself: if I
catchand don't rethrow, that swallow should be visible to the*hook. Otherwise function hooks reproduce the emptycatch {}that started all of this, just with better types around it.yes Ship it please
1. Can a plugin re-draw a component outside a dispatch?
Every ui.render example is drawn while an event is in flight. Our renders outlive the tool call that started them by tens of minutes. Is there a path to hold a component and push updates to it — a handle, a $.clock tick, an explicit $.ui.update — or is ui.render strictly reactive to engine draws?
2. Will the element sets include images/media, on terminal as well as desktop?
Approval-of-an-artifact before an expensive or irreversible action is the strongest non-code case for a render hook, and a text-only component cannot carry it. If it is text-and-layout only, say so plainly in the doc.
3. Is there an intended $ primitive for exclusion over a machine-wide resource?
Two sessions on one box each starting an NVENC render fight over one GPU; we hold a lock file and hope. Advisory lock with a declared lifetime (session/machine), or is a resident localhost daemon the permanent answer?
Strong yes. Solo operator, 16 command hooks across 5 events, 7
CLAUDE_CONFIG_DIRprofiles sharing onesettings.jsonby symlink, Windows 11. Skipping what the thread already has (spawn arithmetic, fail-open,$reach, sibling-not-successor). Five things I could not find asked, each from something running on this machine.1. Does
$withholding constrain the agent's tools, or only plugins' side effects?Video ⓸ and §4.2 say a withheld noun cannot be invoked by any plugin below. @medsel06's "take write capabilities away from my own default session", @984fht6's "hand a headless run a
$with no network", and @ahjin2's "mechanical constraints hold" all read it as constraining what Claude can do through Bash, Write and MCP. The PDF never says that. It depends on whether the core tool implementations at ⊥ are themselves built on$.fs/$.process/$.network, or reach the OS ambiently.Why it matters here: my Bash guard and my
permissions.denylist are both spelled in bash (rm -rf,rmdir,shred). PowerShell is the primary shell on this box.Remove-Item -Recurse -Force,rd /s /q,git clean -fdx,python -c "shutil.rmtree(...)"all pass. @deafsquad is right that structured args do not close this; the only thing that would is an effect-layer boundary on what the Bash tool's child process may touch. If ⊥ does not go through$, please state plainly that §4.2 is a plugin sandbox and not an agent sandbox, so nobody ships a compliance story on the wrong reading.2. Where do today's three permission layers sit in the chain?
Today a tool call is decided by (a)
permissions.allow/deny/askrules, (b) the auto-mode classifier, (c)PreToolUsehooks, and the precedence between them is undocumented. Extending @richardkmichael's question: is each a hook onpermissions.decide(ortool.call) at a fixed position, and can the*hook observe the classifier's verdict and reason? On my install the classifier denies specific shapes (git commit --amend+ force-push, somegh pr createforms) with no reason surfaced to any hook, so I keep a memory file of what it blocks. Apermissions.classifyevent withe.reasonon the return would replace that file.3. Module resolution must survive plugin auto-update and multiple config dirs
One of my four
SessionStarthooks exists only to repair a plugin's hook paths after auto-update rewrites the versioned cache directory (anthropics/claude-code#46915). Ifhooks.jsonmodulesare resolved by path underplugins/cache/<name>/<version>/hooks/, function hooks inherit that breakage on day one. Please resolve modules by plugin id, and say whetherregister(on)and the registration order live perCLAUDE_CONFIG_DIRprofile or per machine. Seconding @yonatangross and @Ruzo: an unknownmoduleskey on an older build must be skipped, not drop the file.4. Dead-hook data point, and a Windows spawn floor
2 of my 16 registrations are provably dead on this machine: one pins a Linux repo path, one walks
/procfrom native Python. Both fire on every Edit / Stop, exit 0, and have never been reported by anything. Same class as @DolphusCY's eight weeks and @yourstrulyeden's never-firing event. Registration-time validation that a module's declared effects are reachable on this surface would have caught both.Spawn floor, Python 3.12, Windows 11, 10 runs median:
Higher than @deafsquad's 86–99 ms. One measurement the thread does not have: with 7 concurrent subagents on this box, a subagent's
Readtook median 42 s andBash68–91 s wall, model time 3–8 min against 20–30 min per agent. Hooks and the classifier contending for CPU were most of it. Whatever the per-hook dispatch cost, please say whether the chain runs per agent, in parallel across agents, and whethere.agentis ontool.call.5. A shipped precedent for "fail open, loudly", and a question for lifecycle gates
My
Stopgate blocks a turn that edited files without a verification run. It fails open after three blocks, but only by emitting asystemMessagethat saysthis is a FAILURE, cite it in the summary, and the one escape is a.verify-skipfile whose content is the citation. That is @deafsquad's point 2 (a throw must leave an artifact) and @gmoneyn's "make the effective state observable" running here since late August, and it works. Please make it the engine default rather than something each author rediscovers.Two things that gate needs and the doc does not give it: (a) it is two registrations (
PostToolUsetrack +Stopgate) sharing state through a per-session JSON file because there is no in-process state across events; a closure in one module fixes that, but only if turn end has a noun at all (@PrimeLineDirekt's table). (b)stop_hook_activetoday tells a Stop hook it already blocked once this turn; a chain hook needs the same fact onnextor it loops.Happy to run any preview build against this profile set; the Windows + multi-profile shape seems under-represented here.
---
_Drafted with Claude Fable 5.1 from my hook inventory and the full thread; all measurements are from my machine._
+1, please make the app's own chrome part of the public
componentsset: prompt bar, sidebar, status line not just in-transcript drawings likeToolUse/AskUserQuestionThe redaction hook in the second video does something the spec doesn't require: it swaps the secrets and appends a note with the count, so the model is informed of the change rather than misled by it.
That seems worth promoting from good practice to contract.
Proposal: any hook that rewrites e before next, or rewrites the result after it, should be able to attach a structured annotation that travels with the value. Something minimal — the plugin that made the change, what kind of change, and how many.
Two reasons.
For the model: without it, a hook that silently rewrites a tool result gives the model a false picture of the world. The video's note fixes that by convention; a contract fixes it by construction.
For auditing: on("*") already gives a full trace of which calls happened. It doesn't record which values were altered on the way through. With annotations, a session produces a complete provenance record of both — not added afterwards, but as a property of the architecture.
This matters most where several plugins stack. If A redacts, B reformats and C summarizes the same result, the model currently sees the last value with no history. The annotation makes the chain legible.
Related question: does next.origin propagate through nested dispatches, or only name the immediate caller? For provenance the chain matters more than the last link.
Yes — and the reason is narrower than "more extensibility would be nice."
Some context, because I think the non-developer end of your user base is underrepresented in these threads: I'm not a programmer. I run Claude Code over a personal knowledge vault of a few hundred Markdown files on Windows. I have five hook scripts in
settings.json. They are registered eight times acrossPreToolUse,PostToolUseandStop— one of them three times on its own.That ratio is the feedback. Five jobs, eight registrations, because no single lifecycle point can see what I actually need to guard.
1. A Bash hook sees the command string, not the effect
My most important guard protects three files that cost hours to rebuild. It tries to block the pattern that destroys them:
open(path, "w")on a vault file truncates before it writes, so a script that dies mid-write leaves an empty file behind.It cannot reliably do this, and the reason is structural. When the model runs
python script.py, the command string contains no code. There is nothing to match against. My index file was zeroed twice in one month — 19 August and 31 August — the second time twelve days after I had written "always write to a temp file, then rename" into the project instructions as a hard rule. A regex over command text was never going to catch it, because by the time the command exists the interesting part is inside a file.So the guard now does something strictly worse than blocking. It:
That is damage detection standing in for damage prevention, and it needs three separate registrations to cover the lifecycle. A hook that wraps the file write itself — whatever issued it,
Write,Edit, or a subprocess three levels down — collapses all of that into one guard that actually works. If Function Hooks can intercept at the effect level rather than the tool-invocation level, that alone is the feature for me.2. The same gap shows up again in a completely different hook
My line-ending normalizer is registered on
PostToolUseand again onStop, where it rescans the whole vault. The second registration is pure workaround: script-driven writes never reachPostToolUse, and CRLF creeping back in makes git report entire files as changed, which then kicks off pointless cloud syncing. I converted 21 files to LF on 19 August. Five were CRLF again by the 20th.My
Stopreminder hit the mirror image of this. It checks whether the session wrote to my log file, and it was firing on sessions that had — because the safe-write pattern (temp file, then rename) doesn't look like an append in any command string. It now also stats the file's mtime. Two heuristics guessing at one fact the runtime already knows for certain.Neither of these is an exotic requirement. Both are "did this file change, and how" — and both needed workarounds.
3. Ordering is load-bearing and currently implicit
I have two
PreToolUsehooks on the same matcher: a path guard and a frontmatter validator. The path guard must win, because "you may not write here at all" makes "your frontmatter is malformed" irrelevant and confusing. Today that's a flat array and I'm trusting it. Registration-order nesting as a stated contract is better than an array I hope is ordered.One concern, and it's the reason I'm writing at length
My hooks are Python. I could write them because they're small scripts with a documented stdin/stdout contract — read JSON, print JSON, exit. That is a low enough floor for someone who is not a developer to clear with Claude's help.
If Function Hooks are TypeScript-only and eventually replace
type: "command"rather than sitting alongside it, that floor rises sharply for people like me. Please keep the subprocess form as a first-class citizen, not a deprecated fallback.A smaller version of the same worry, which cuts the other way and argues for the proposal: my vault path contains non-ASCII characters (
G:\Το Drive μου\). Every one of my hooks writes its JSON response as explicit UTF-8 bytes withensure_ascii=False, and hardcodes an absolute interpreter path, because shelling out on Windows with a Greek path and Greek-language denial messages is genuinely fragile. In-process functions would delete that entire class of bug. I want that — I just don't want it to be the only door.Will Function Hooks expose model capability discovery/resolution, effective context-window and compaction resolution, and built-in tool schema/model validation as hookable events; not merely model calls and UI rendering?
My biggest frustration with Claude Code is that as soon as you move from
api.anthropic.comto another gateway, it kills context management. My second biggest is the enum that was added to Agent meaning if your gateway serves non-claude models, they have to be used in Dynamic workflows that do not supportSendMessageresulting in forks.I run a self-hosted mesh system of what is normally 30-40 Claude Code agents across ~20 repos and multiple remote machines all hosting multiple repos themselves. They coordinate over a shared task board, a messaging system, and a shared memory store, backed by an MCP server and a pile of hooks. So this lands on a real surface, and several of these primitives map straight onto things I currently hand-roll, with varying degrees of success.
Context injection is the one that matters most to me. An indexer's whole job is to put "before you touch this file, here are its callers, the symbols it depends on, and the two times someone broke it before" in front of the model at the right moment. Today I do that through
UserPromptSubmitstdout, which is blunt and, worse, a mutation of the cached prompt prefix. @yonatangross already asked what event carries theadditionalContextanalog. I want to narrow that to: what are its cache semantics? If injecting context rewrites the prefix every turn, you've traded a retrieval win for a prompt-cache miss, and at fleet scale that's a non-starter — I'll keep the worse retrieval before I eat the latency. @poteat's answer to @Helban (the model sees what it asked to write, plus acontextfield) is exactly the right shape. Make it a first-class documented contract rather than an "I believe the prototype supports it," and makecontextrequired whenever a hook forwards a changede. A model reasoning about content it was never shown is a bug I've chased outside Claude Code, and it's miserable to find.Second:
on('*')deletes an entire category of screen-scraping, and for an agent mesh it's important. I detect whether an agent is alive and working by parsing terminal panes and probing sockets. I'm fixing a bug this week where that liveness check can't see one kind of runner socket, so a parked-but-reachable agent gets flagged dark and fires a false alarm every 30 minutes. That's not a hook bug — it's the absence of an event to subscribe to. A typed event stream is the difference between inferring what an agent did and observing it. And then there's the mesh twist on the silent-failure point everyone's raising (@DolphusCY, @ipoltorak): in a single-agent setup a silently-failed hook loses telemetry; in a mesh, a peer agent makes coordination decisions on that missing signal, so a success-shaped failure doesn't stay local — it propagates as false state into another agent's context. Typed returns plus$tracking is the fix, and I'll second hard that the wildcard must see denials, not just successes.Third, a request not a cheer: capability removal has to be loud. As an example: I enforce data walls — some agent identities must never read certain repos' memory/tasks/messages — and I enforce them in code (obviously), not in a prompt, because a limit written in prose leaks exactly when you don't expect, and usually with the worst consequences. Mechanical removal from
$, inherited downstream, is what I actually want. But @DolphusCY's caution is important: a revoked affordance that returns a benign empty instead of throwing is how a wall silently becomes a leak that still looks good.Deniedhas to be a value the caller is forced to handle, or you've rebuilt2>/dev/nullwith fancy paint.Design questions, ordered by how much the answer changes my migration:
$capability-withholding inherit into sub-agents and headless (-p) sessions? @puretensor asked the sub-agent half. But a lot of my fleet runs as headless resumed sessions, not interactive terminals. If function hooks only fire interactively, half my agents are uncovered and the guarantee has a hole exactly where I can't watch it. State the session-kind coverage explicitly.$and a fixture format decide whether my guard tests port or get rewritten, and that's the difference between a guardrail I trust and one I hope doesn't kill me.on('schedule')not exist? Seconding @ipoltorak — a chunk of my "hooks" are a cron pretending to be event-driven (wake, heartbeat, health probes). If timers aren't in scope, say so, so we stop wearing them as hook costumes.On the presentation: it's middleware. The onion is a nice touch, but @andreimaxim is right that
A(B(C(⊥)))is doing a lot of theatrical work for "earlier registration wraps later." Ship the middleware; keep the$.Yes, I'd use this. Solo dev, one iOS/macOS app, four shell hooks in
settings.json. All four spend most of their code on plumbing instead of on what they actually do.The worst offender is a
PreToolUseguard. It reads stdin, pulls the command out withjq -r '.tool_input.command', matches a few patterns, and when it wants to stop me it hand-builds the response:jq -Rs .to escape the reason string, thenprintfthe JSON around it. Thirty-seven lines, and about eight of them are the actual rules. GettinghookSpecificOutputandpermissionDecisionright was trial and error, because malformed JSON fails silently. No error, no hook, no clue.The other three are bad in a different way.
SessionStartshells out toosascriptto get my Ghostty terminal id and writes it to/tmp/claude-ghostty/$session_id. ThenNotificationandStopread that file back and write pipe-delimited strings (message|title|sound|type|term|cwd|tid) into a queue directory. That whole dance exists only because two hooks in one session have no other way to share state.What would actually change my setup, in order:
tool_inputby printing it and squinting./tmpstops being my IPC channel.Component rendering is the flashiest thing in the demos and the one I'd use least, but I'd rather it exist than have the API shaped without it.
One request: please keep shell hooks working. Mine are zsh because zsh is what I debug in, and a TypeScript-only migration means rewriting four things that work.
The capability model in section 4 is what makes this interesting from an admin perspective. Today a shell hook runs with the full authority of the user, which makes hooks hard to deploy centrally. A plugin whose side effects are exactly its calls on
$, with no ambient file system or network, is something I can reason about.The allowlist on
plugin.registermatches by name. Is signing or version pinning planned, so the plugin that loads is the code I reviewed?claude plugin validateis a CLI command. Does the engine refuse to load a hooks-module that fails validation, or can a developer skip it and still run?Managed settings stay a file. What stops a local user from editing it, or from running an installation outside the managed configuration? A prepended plugin only helps if it is guaranteed to load.
Listing 5 logs to
$.ui.log, so the record stays on the endpoint. Is remote log shipping an intended use case, or do I express it by hooking the network event and branching onnext.origin?Ordering as enforcement is elegant, and a hook on
*is the cheapest audit trail I have seen proposed for an agent. Count this as a yes from someone who would deploy it.@F3d0s Really good questions, thanks
To the extent possible, we are intending function hooks to cleanly integrate with plugins as they exist today, as a new type of hook. Therefore dependencies, versions, etc. will all go unchanged.
The engine does refuse if the author does things like
$[<arbitrary expr>], both during plugin registration at startup and during the manual validation tool.We're not changing anything on that level with the function hooks work. You need enterprise endpoint management to prevent certain scenarios - this is rather well-documented; I've attached a screenshot with the relevant part highlighted in our docs re this topic.
Obviously, you need support from the OS.
<img width="400" alt="endpoint managed settings" src="https://github.com/user-attachments/assets/e3a93cbe-b55a-4f15-83ad-49c8cfc581c6" />
Yes, we want really good OTEL support day one for example, like we do for the rest of Claude Code, but of course you can roll your own bespoke solution via
$.http.Strong yes from a solo operator running a small but load-bearing hook stack (deny gates on PreToolUse, SessionStart context injection).
One practice behind that stack: every hook lands with a false-positive regression test in the same commit — fixture events the gate must not block — because a guard that fires wrongly erodes trust as fast as one that silently does nothing. The weak point is that the fixtures are hand-rolled JSON, so they drift from what the harness actually sends, and a green suite ends up certifying a replica rather than the real contract.
Typed function hooks could close that gap almost for free, so my one ask is narrow: export the event types and the chain composition itself — the fold that wraps registered hooks in order and routes errors/returns — as importable pieces, not just as internal machinery. Then anyone can unit-test their hooks by dispatching synthetic events through the real composition with a stubbed
$, and nobody needs an official test framework, because the real parts are the test kit. @Monte9's prototype effectively demonstrates this shape already; I'd just want the shipped version to keep that surface public.Maintainer of fable-director here: a cost-governance plugin (pre-budgets, a delegation gate, per-agent metering), 16 command-hook registrations on 12 events, all Python. Skipping what the thread already has (spawn tax, fail-open,
$reach, sibling-not-successor). I read the prototype shipped in 2.1.260 behindCLAUDE_CODE_ENABLE_FUNCTION_HOOKS, and ran it: a plugin with{"modules": ["./probe.ts"]}inhooks.jsonloads under--plugin-dir, and the chain runsengine.create → session.start → prompt.* → turn.start → turn.step → turn.completein aclaude -psession. So the asks below are against what runs, not against the PDF.One Linux data point first (Debian/Crostini, 8 cores):
python3 -c pass54 ms, our hooks 67–94 ms each. About 80% spawn, which confirms @gbrussich52's "Python gates are almost pure spawn".1. Usage on events, with the cache split. Measured in the prototype:
turn.completereceives{answer, durationMs, aborted, turnId, reason}and resolves to{text}; no event and no$.model.completeresult carries token usage. Every number this plugin enforces comes from parsing the undocumented JSONL transcript, and we ship a schema sentinel that suspends enforcement when 20 records show no usage fields. Measured hole: Workflow agents never appear in the parent'stoolUseResult(5 Workflow calls, zero nested usage; 9.2M fresh input tokens invisible in one session). Ask:e.usage = {input, output, cache_read, cache_creation, model, effort}onturn.complete/turn.stepand on the agent completion event, plusresult.usageonmodel.complete. The split is not optional: on Fable 5.1 cache reads are 0.025×, so a total says nothing about cost.2. An agent completion event with a reason.
agent.spawnexists, and its{model}rewrite is exactly the in-engine delegation gate we run as a PreToolUse script today. There is noagent.complete. TodaySubagentStopnever fires for agents killed at the five-hour wall, byTaskStopor by a fatal error: 30 agents shown "in flight" for 1709 minutes on one workflow session, and we shipped a transcript-silence heuristic to reap them. Ask:agent.completeguaranteed for everyagent.spawn(finally semantics), with{agentId, parentAgentId, runId, reason: done | aborted | error | rate_limited, usage}. And a sentence confirming that every spawn route (Agent tool, Workflowagent(), fork, background session, teammate) dispatchesagent.spawn; Workflow agents already bypass the parent's accounting.3.
effortbesidemodelonagent.spawn. The prototype checks{model}only. Older builds silently ignore an agent'seffortfrontmatter; we measure that aseffort_ignoredatSubagentStop. Effort is a cost lever of the same order as model: Fable 5.1 at low effort costs the same tokens as sonnet and 5.5× the USD (n=3).4. Session reads for quota and context. The
statusLinecommand receivesrate_limits(five-hour and weekly percent, reset time) andcontext_window; hooks receive neither, so our statusline writes a file the gate reads back. The prototype hassession.model/turnCount/messagesbut nosession.rateLimits()orsession.context(). Ask: those two reads, and an event when a limit changes state ({kind, percent, resetsAt}; @Ruzo's item 4 made concrete). Our gate sizes a workflow fan-out to the remaining window; measured before it existed: 15 agents dead at the wall with zero usage, ~5.6M tokens re-run after the reset.5. Cache-loss events: compaction, resume after idle, model switch. None is in the prototype's list, though PreCompact/PostCompact and PreModelSwitch exist as command hooks. On 5.1 the cost center is cache writes (60% of spend over 21 days); 76% of cache resets are a resume after more than an hour idle, 15% a model switch. A plugin that governs cost must see
session.resume {idleSeconds}and model or effort changes.6.
$.model.completebilling. Which quota does a plugin's own model call hit, under which model, and does the*hook see it? A plugin calling Opus on every turn is exactly what a governance plugin must be able to deny. It is hookable already, good; it needse.modelandresult.usage.7.
$.storescope and atomicity. 4 MB JSON per key, plugin-scoped: per session, per machine, per cwd? Our budget is per cwd with a session lease, and a 15-agent fan-out writes concurrently (flock plus atomic rename today). Ask: a declared scope (session | cwd | machine, @gbrussich52's lifetime plus cwd) and a compare-and-set.8.
turn.completeparity with Stop's block. Today Stop returnsdecision: blockwith a reason andstop_hook_activemarks the re-entry. The prototype mapsturn.completeto{text}. Can a hook refuse the end of a turn with a reason, and how is the re-entry marked so the hook does not loop?Three observations from the run, for the open questions upthread:
hook failed: … (turn.complete; skipped; its last next() run's result stands), at ERROR level in--debug-fileand nowhere else. @deafsquad's "fail-open loudly" is oneui.noticeaway.$.store.set/getround-trips.$.fs.writeFilerejected{path, content},{path, data}and a relative path alike withfs.writeFile: takes a path (host check); the shipped binary carries noclaude-codedeclaration file I could find, so the typings that would have told me the shape are not there yet. Guessing an API against a host check is the shell-hook experience again.on("*")settles in 30–50 ms per event on this machine,next()included, and 26prompt.sectiondispatches land in the same millisecond, so they are parallel. Not a dispatch measurement, just a note that the floor per event is the underlying work, not the hook.None of this blocks shipping. Items 1, 2 and 4 remove three of our scripts and the transcript dependency outright.
@bachmors
I'm thinking a
next.tracewould be appropriate, present after your hooks below have executed (i.e. after you awaitnext(e)). This would include data like timing and event/return value snapshots, between every link in the chain (below you anyway). We already track this data in the engine and it's immutable, so we can provide it without imposing a $N^2$ serialization cost.As folks have said (@deafsquad as well), without a mechanism like this, the Koa algebra can be opaque; org admins ought be able to inspect not just every event, but also every individual hook.
@42tahara
Narrow type support, as demonstrated in the videos, is very high priority for us. re the chain composition, in the ideal case the mechanism is well-documented and parsimonious enough to be eminently replicable; it's just a
reduceRightwith accoutrements. I'll consider what would make sense here though.@frsorrentino
I disavow knowledge of any such flag.
If such a flag existed, I would put the typings under a
/plugin-typescommand.@poteat Correction accepted. I had not found
/plugin-types; it writesclaude-code.d.ts(5,619 lines on 2.1.260) andclaude-code-mcp.d.ts, and it answers thefs.writeFilequestion on the spot:(path: string, text: string), positional, my guess of an options object was the error. I retract "the typings are not there yet".Reading the declarations against my eight items, for the record:
ModelForkUsagealready carriesinput_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokenson$.model.fork's result. So the cache-split usage type exists; item 1 is asking for the same record onturn.step/turn.completeand on the agent completion, and item 6 for it onmodel.complete, which today resolves to a barestring.turn.stepcarriesstopReasonwith'compaction'and'model_context_window_exceeded'. That is a real signal for item 5 (compaction) that I had missed; resume-after-idle and model or effort switch still have no event.AgentSpawnInputpinstool_use_id,fork,parentModel,permissionModeand lets a hook rewritemodel,subagentType,prompt,background,cwd. Noeffort(item 3). A plugin's own$.agent.spawn()resolves with{ model, text, isError }when the agent settles, but a spawn the model raised has no completion event a hook can see (item 2).$.storeis a JSON file per plugin under~/.claude/plugins/store/: machine scope, no compare-and-set (item 7).$.sessionreads rate limits or the context window (item 4).Items 1 to 8 stand as asked, and item 5 is smaller than I wrote.
Yes from me, with two questions I haven't seen in the thread yet.
My setup is small: 6 Python hooks on 3 events (PreToolUse, PostToolUse, Stop). Two of them match what others describe here: a Bash guard that is regex over a raw command string, and an MCP guard that scans Jira/Trello tool arguments for local paths before anything leaves the machine. Structured
e.argsontool.call, covering MCP calls too, replaces both and removes the bypass class I already know about.1. Transcript access. My Stop hook blocks the turn once when code was edited and no test, build or lint ran after the last edit. Today it reconstructs that by parsing the transcript JSONL from
transcript_path. Does$expose turn history, or is a hook expected to keep its own state from a*listener across the session? Either works, but the answer decides whether a Stop-shaped gate is a few lines or its own little store.2. Portability. The same six scripts run under Codex and Cursor through a thin adapter, because all three harnesses hand a JSON payload to a process. A TypeScript function against
$only runs where$exists. I'd still write the safety-shaped hooks as function hooks for the typing and the capability model, but it would help to know whether the command-hook interface stays alongside them, since that is what keeps one policy usable in more than one tool.Two failure modes from a live deployment. One I have not seen raised here, one that seconds @sidhartha1s from a different cause.
For scale: 12 hook events wired, though 11 of those are a single extension registering broadly. My own is one
PreToolUseguard. That imbalance turns out to be the second point.1. A
PreToolUseBash guard cannot see what it is guarding.My guard blocks recursive deletes inside
$HOME. It receives the command as the model wrote it, before the shell resolves anything, sorm -rf $SB/xarrives with the variable intact and there is no way to know what it points at. The guard takes the conservative branch and blocks with a generic reason, on the logic that under-blocking costs data and over-blocking costs one override. That is still the wrong outcome: it fires on scratch directories whose path is held in a variable, and a guard that blocks legitimate work is a guard that gets switched off.@Helban's "match on the parsed call, never on prose" gets close and I would extend it. For Bash, structured arguments are not sufficient, because the argument is itself a string the shell has yet to interpret. Typed hooks do not fix this on their own, since the hook runs before execution by definition. What fixes it is the event carrying the resolved command, or
$exposing a resolution against the session shell's environment. Without one of those, every Bash guard stays a string matcher with a blind spot, in a better language.2. Registration living in a file another process owns.
Seconding @sidhartha1s, different cause. One of my hooks exists only to repair
~/.claude/settings.json, which a VS Code extension rewrites on every window restart, reverting each hook command to a barenode.The property that makes it expensive is the direction of the failure. A bare
noderesolves under VS Code, which inherits the login shell, and resolves to nothing under a GUI-launched host, which gets the launchd PATH. So the hooks keep working in the one environment that broke them, and fail silently everywhere else. Observed with the extension version unchanged,settings.jsonrewritten three seconds after the window came up. My nightly sweep catches it at 04:00, which cannot cover a restart during the working day, so the repair had to move to session start and repair rather than report.Plugin auto-update and a co-installed extension are different causes with the same shape: hook registration lives in a file nobody who wrote the hooks owns.
register(on)in code is worth as much for this as for the drift case, on one condition. If modules are resolved by a path that something else can rewrite, function hooks inherit the same problem on day one, and I would rather the resolution key be the plugin id.Two measurements, one confirming @heatherhelen and one that I think settles the Windows spawn disagreement rather than adding a third number to it.
1. @heatherhelen is right, and the reason both of us are right is that the failure mode CHANGED
That is not what I measured, and I said in my last comment that it truncates mid word with no file and no preview. Both readings are correct, at different times. I have them dated from the binary:
Their 2,000 is
yor. The limit it fired against was 10,000, not the 8,000 that is there now, and the whole write-to-disk-and-preview path is gone. So between those two builds the ceiling moved, a second ceiling appeared that is counted in LINES, and the failure changed from "you get a different, smaller, unparsed payload" to "you get the same payload with the end sliced off." Those two failures want completely different handling, and a hook that coped with the first does not necessarily notice the second.The part I would put in front of whoever writes the docs is this. We characterised this once each and we were both left holding a stale model. My own notes carried the write-to-disk behaviour as current for weeks after it stopped being true, and I only found out by reading the binary again for an unrelated reason. Nothing announced the change, because nothing announces a truncation at all.
Two things follow for the proposal, and they are small:
claude.exeand grep, which I do not think is the intended interface.One detail of theirs I did not have and cannot check from here, that the 2,000 characters came back raw and unparsed, escape sequences and all. If the fallback path also changed the encoding rather than only the length, that is a third distinct failure hiding in the same limit.
2. The Windows spawn numbers disagree because "python on Windows" is not one number
@deafsquad measured 86 to 99 ms for a bare Python hook subprocess. @sidhartha1s measured 152 ms for
python -c pass, Python 3.12, Windows 11. Both on Windows, same shape of command, 60 ms apart. @gbrussich52's macOS comparison then generalises the first of those into "Windows pays ~90 ms per spawn", which is the reading I want to push back on, gently, because I do not think one Windows box can carry that sentence.Third Windows box, Python 3.14.3, n=15, median wall time from the caller. I varied one thing at a time instead of reporting a fourth number:
The
pylauncher nearly doubles the floor, 29 ms to 54 ms, before your code exists. It resolves a version and then starts a second process. Anything invoking hooks aspy script.pyrather than by interpreter path is paying that on every hook on every tool call, and it is the single cheapest thing on this list to remove.The other two lines are worth having because they are the parts people assume.
pythonwis free, 30 ms against 29 ms, so choosing the windowless binary to stop a console flashing costs nothing. And four stdlib imports take it from 30 ms to 43 ms, which is a third of a bare interpreter again.So our floor is 29 ms where @sidhartha1s measures 152 ms on the same nominal command. I am not claiming either is wrong. I am claiming the spread is real and structural, and that a doc arguing "spawn is the cost" should say the cost varies by roughly 5x across Windows machines depending on launcher, interpreter version and what the antivirus is doing. Anyone sizing a migration off one Windows number will be wrong by a factor.
3. The eighth rediscovery
@gbrussich52 made it seven in a footnote, a piped-install gate blocking a benchmark command because the payload sat in a heredoc. Eight, and it is the same mechanism to the letter. My own heredoc guard blocked me twice while I was writing the previous comment, once because the text I was quoting contained the pattern it watches for.
The guard was right both times. The content matched and the command was harmless. That is not a tuning problem, it is what content matching IS, and it is why I would rather match on
{ tool: "Bash" }than on anything I can accidentally write in a sentence.Measurements from the shipped engine, 2.1.260, native Windows build. A correction of my own first.
Correction. In the collapse comment I wrote that
updatedInputis first-wins. That described our own merge loop, not Claude Code's. @gbrussich52 measured two rewrites → neither applies. On 2.1.260 I get a third answer, so here is the exact setup: onePreToolUsematcher group ("matcher": "Read") with two command hooks, each printing{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"file_path": …}}}, the first pointing atb.txt, the second atc.txt, the model asked to reada.txt. The tool readc.txt. Reverse the two hooks in settings.json and it readsb.txt. Last listed wins, and the debug file logs each hook assuccesswith its own payload and nothing about the first having been overwritten. So: not first-wins, not neither. Last-wins by list order, silent. If the two rewrites you measured sat in different matcher groups or different settings files, that may be a fourth behaviour and worth pinning the same way.Profile, so anyone can rerun: scratch
CLAUDE_CONFIG_DIRandHOME,env -i, noANTHROPIC_BASE_URL,settings.json={},CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1,claude -p … --model haiku --debug-file … --plugin-dir <one dir per plugin>. Each plugin:.claude-plugin/plugin.jsonplushooks/hooks.json={"modules":["./probe.js"]}. One plain hook per event per plugin is enforced (a secondon("tool.call")throws), so N chained hooks means N plugin dirs. Numbers are the transcript gap from thetool_useblock to itstool_result, tool =Readof a 6-byte file, median of 3.| case | ms |
|---|---|
| no hooks | 15 |
| 1 function hook, pass-through | 16 |
| 25 function hooks, pass-through | 20 |
| 1 command hook,
node -e "setTimeout(()=>{},50)"| 166 || 8 command hooks, 50 ms each | 401 |
| 8 function hooks,
await $.clock.sleep(50)each | 424 || 1 command hook, 300 ms | 422 |
| 8 command hooks, 300 ms each | 640 |
| 8 function hooks, 300 ms each | 2427 |
1. Serialization. The chain is strictly serial: 8 × 300 ms settles in 2427 ms, 8 × 50 in 424, about 3 ms of host overhead per link on top of the sleep. The same eight as command hooks run in parallel: 640 ms for 8 × 300 is one spawn plus contention, not eight. (The 1-hook rows put a node spawn at about 115 ms on this box, the same machine that gave 86 to 99 ms for python earlier in the thread, which supports @yourstrulyeden's point that the spawn floor is per machine and per launcher, not per OS.) Below the sleep the fold is nearly free: 25 pass-through function hooks add 5 ms over no hooks at all, well under a millisecond per link, so the dispatch-floor question goes to function hooks. The cost that matters is the one the algebra imposes: every awaiting link adds its full latency, because nothing above it can proceed until its
next(e)resolves. Seven parallel 113 ms guards folded into a chain are about 800 ms, not 245.The host already writes the timing half of
next.traceto--debug-file, per dispatch, naming every link:Wall time only, DEBUG only, file only. No value snapshots. There is no
traceanywhere in the 4,652-line declaration set embedded in the binary (/plugin-typesappends the tool tables on top of that).2. Route-around. A hook that throws before
next(), second of four in the chain:That is the whole record. The hook above it received
{ ref, result, text }from below, indistinguishable from a chain in which nothing failed. No transcript row, nothing on stdout under-p, no$.ui.logline (a module that fails to load does get one:hooks module did not load: …). A withheld noun fails the same way and names its withholder:hook failed: chain-01: $.clock.sleep: removed by plugin \withhold2\(tool.call; skipped; …). So catch-log-route-around is what shipped. The level is right; the surface is the debug file and nothing else.3. Re-entrancy. Two
Readcalls in one assistant message (samemessage.id), a guard ontool.callthat awaits$.fs.existsand then sleeps 3 s. Call #2 entered the guard while #1 was still inside it (inflight: 2), both withnext.origin === "engine". The guard's own$.fs.existsdispatched withorigin: "guard", was seen by the outer observer, and nothing was skipped for either engine dispatch. So it is per-dispatch lineage, not a per-hook flag: a parallel call is never silently waved through. The lineage prints as it nests:skipped: re-entry (its own frame is being dispatched; origin guard > obs). Side note: Claude Code dispatched #1 while #2 was still streaming, about 700 ms apart, so "parallel" tool calls reach a hook staggered.4. Withholding (@sidhartha1s's question). An
engine.createstep that leavesfsout, or returns{}, constrains plugins only:$.fs suppressed by withhold, inner plugins'$.fsops rejected, and the model's ownWritetool wrote the file both times.$is the plugins' capability table, not the agent's.5. Two things I did not expect.
$may only appear as$.noun.event(...).$ && $.fs, passing$to a helper, or passing theengine.createnext(e)result as an argument all refuse the load:$ itself is used in a LogicalExpression (bound, passed, spread, returned or read); $ is always spelled $.noun.event(...) at the call site. Reasonable, and it bites anyone who writes a logging helper first.on("*")registers on every event including every$op (fs.writeFile,session.messages,model.complete, 55 names on this build). An outer plugin therefore sees every inner plugin's calls with their full payloads, the text of awriteFileincluded, and may rewrite or deny them. That is the documented "a hook above the caller passes it on, rewrites it, refuses it", and it makes list order a trust boundary: managed plugins are outermost, so an org plugin reads and can veto everything a user plugin does through$.For the record: each module is its own worker (25 load in about 50 ms),
$.fsis fenced to the project by string comparison (an 8.3 short path to the same file is "outside the project"), and two function-hook rewrites of the same field resolve by chain position, the link nearest core wins, both orders tested. That is the one place the algebra is cleaner than the settings hooks: there is no merge to get wrong.@yourstrulyeden — measured today rather than recalled, on the two engines this account runs: the CLI at 2.1.258 and the VS Code extension's bundled binary at 2.1.260. Method: a throwaway project whose only hooks are a
SessionStarthook emitting anadditionalContextof N characters or N lines and aPreToolUsedeny whose reason has N lines; one fresh session per case; the session's own transcript and the model's own report as the two witnesses.On the 2.1.258 CLI your constants did not act. 8,100 characters and 201 lines of
additionalContextarrived whole and parsed; 1,000 lines arrived whole; a 52-line, 4,100-characterpermissionDecisionReasonarrived whole in the tool result. The boundary is 10,000 characters of theadditionalContextfield itself, not of the hook's stdout: 10,000 arrived whole with 10,206 characters of stdout, 10,050 did not. Over it the harness still persists and previews — the early-August mechanism:<persisted-output>,Output too large (9.8KB). Full output saved to: <session>/tool-results/hook-<id>-1-additionalContext.txt, thenPreview (first 2KB):and the first 1,999 characters. What changed since our 2026-08-10 measurement is the preview: it is now the parsed field with the lines intact, where on that build it was the raw JSON wrapper with the escape sequences visible. So the answer to your question is yes on the early build — the fallback changed the encoding as well as the length — and no on this one.On the extension's bundled 2.1.260 engine, same rig, five fresh sessions: identical. 8,100 and 10,000 characters arrived whole and parsed with the model reporting the last line; 10,050 was persisted to the same
tool-results/hook-<id>-1-additionalContext.txtpath withOutput too large (9.8KB)and the same 1,999-character parsed preview; 201 lines arrived whole; a 52-linepermissionDecisionReasonarrived whole. So on these two paths, run headless, the two builds enforce one cap and one mechanism, and neither is the 8,000 / 200 slice.Not measured on either:
SessionStartwithsource: compact;systemMessage,reasonandstopReason; and the extension's interactive rendering, since the bundled binary was driven headless. If your constants act on one of those, that is where the difference lives.One practice, since the thread has now characterised this cap twice and found it moved in between: a hook never injects a document. It injects a pointer or a fixed-shape line, and anything bulky goes to a file the session is told to read. A payload that cannot exceed the cap by construction never has to be measured against it, which is the only kind of payload that survives the cap changing without notice. Concretely, our
SessionStarthook after a compaction prints under two thousand characters no matter what happened; everything it used to inject is now a file plus a read step in the procedure, so the session pays for what it opens and nothing arrives truncated. The test we apply while writing the print statement: if the length of what you are about to print depends on how much work exists, it is a document, and it belongs in a file. Where a status line would otherwise grow with the corpus, the fix is a bounded list — a named cap constant, the first N items, and an overflow line saying how many were not listed, so the reader knows the list is partial rather than believing it complete.This does not argue against versioning the limits where a hook can read them, which I would still want; a limit a hook can query beats one it has to discover per build and per surface. The pointer is the other half: with it, the limit is something you check once at design time rather than something every hook has to survive at run time.
_Posted by Claude Fable 5.1 agent on behalf of this account's owner._
When multiple plugins hook into
SessionStartorPreToolUse, mutating raw prompt strings or arguments creates an inevitable conflict: whichever plugin executes last overwrites preceding context.The robust composition model is Immutable Envelope Chaining with Typed Slots:
Plugins contribute assertions to dedicated
contextSlots(e.g.memory:l0_anchor,security:guardrail) rather than mutatingupdatedInputstrings directly. The runner concatenates slots at the final dispatch boundary, guaranteeing that memory injection and safety checks compose without collision.I like the direction here — more extensibility, shared plugins/skills, better customization, etc. But architecturally this starts to feel like a closed-source platform trying to expose enough hooks to be _infinitely extensible_.
At some point the extension API becomes almost as complex as the runtime itself. If plugins can intercept tool calls, filesystem access, UI rendering, engine creation, capabilities, and continuation flow, then all of those things effectively become public ABI. Internal refactors now have compatibility implications.
The debugging story also looks hard. With several middleware layers modifying events, replacing execution, calling
next()multiple times, handling cancellation, etc., a bug can come from the core, plugin ordering, another plugin, capability wrapping, or some interaction between them. To make that usable, Claude Code would eventually need very serious tracing, replay, plugin isolation, versioning, compatibility testing, and probably tooling equivalent to “DevTools for the hook runtime.”This makes me wonder whether the cleaner long-term architecture is:
I think there should probably be a boundary between “I want to extend Claude Code” and “I want to change how the agent runtime works.”
This proposal seems to push the plugin API very far toward the second category. At that point it may be cheaper, easier to debug, and easier for advanced users to understand if the underlying runtime is simply inspectable and forkable, rather than trying to represent every internal mechanism through a permanently stable abstraction layer.
In other words: I'd rather have an open runtime plus a deliberately limited plugin API than a closed runtime with a plugin API powerful enough to emulate modifying the runtime.
Ship it! ASAP need this to shore up my personal dev harness.
@deafsquad Thank you for the setup and the profile. You were right to ask, and the answer starts with a correction of my own.
Correction. "Two rewrites, neither applies" was wrong. I took the model's report of stdout as the measurement. It is not one. I reran with the rewritten command touching a marker file, and in 6 of 6 runs the marker proved a rewrite ran while Haiku reported
hello, the command it had been asked to run. Every "neither" in my earlier comment was the model quoting the prompt back. Lesson I should have applied first: a hook test needs a side effect, not a transcript.What the markers say (macOS, 2.1.260, one
PreToolUsegroup,"matcher": "Bash", two command hooks, each returningpermissionDecision: allowplusupdatedInput, A listed before B):| case | runs | winner |
|---|---|---|
| A and B equal cost | 6 | B ×4, A ×2 |
| A sleeps 300 ms, B sleeps 800 ms | 2 | B, B |
| A sleeps 800 ms, B sleeps 300 ms | 2 | A, A |
| A 300, B 800, third hook returns
{}after 1200 ms | 2 | B, B |In all 12 runs the winner is the hook whose
success:line lands last in--debug-file. The last hook to settle wins. A later empty result does not displace it. Your last-listed-wins is the same rule seen through two equal-cost hooks that finish in list order; on my box a global catch-all hook and a few plugin hooks share the event, and that is enough to flip the finishing order run to run. So the fourth behaviour is not a new rule, it is the rule without the accident that makes it look stable.Why someone would build it this way. Folding results in completion order is the cheapest way to merge parallel hooks, and for
permissionDecisionit is harmless: deny wins whatever order the answers arrive in.updatedInputhas no such dominance. Last-write-wins over a race means the command that executes depends on the scheduler, and the debug file records both rewrites assuccess.Fix I would propose for the settings pipeline. Wait for all hooks, then fold in list order, so the rule becomes the one you measured and it holds on every machine. When a later rewrite replaces an earlier one, log it at the level the route-around uses. If two hooks rewrite the same field to different values, that is worth treating as
askwith both reasons rather than picking silently. For function hooks your finding closes the question: nearest core wins by chain position, both orders tested, no merge to get wrong. That is the argument for the algebra, and it is stronger than any of my prototype issues.Two smaller things:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKSis in my 2.1.260 build too (the strings you quoted are all there). I will rerun the matcher, freeze and re-entry cases from the Monte9 issues against it and close the ones the real engine already answers. Your route-around, re-entrancy and nearest-core results cover three of them already.If someone has already pinned the settle-order rule, say so and I will fold this into theirs.
The prototype issues I filed against Monte9's repo, rerun on the shipped engine (2.1.260, macOS,
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, one--plugin-dirper hook). Evidence is a marker file or a--debug-fileline for every row; the model's text is not used, for the reason in my last comment.Most of what I broke in the prototype does not break here. Matchers are evaluated against the
eeach link receives, so a rewrite above changes what matches below.eis frozen; assigning to it throws and the hook is skipped.[]as a matcher refuses the module at load withthe matcher must be a plain object (a partial of e). A hook that calls$.tool.callbefore its ownnextis skipped for the nested dispatch, the line deafsquad quoted.next.originis set by the engine to the calling plugin; an extra argument is ignored.next()after the hook returned is refused:next() after it settled; refused. Credit where due: the engine is cleaner than the sketch on every one of those.Three things that still break, all in
deny. Hook ontool.callforWrite, file on disk as the witness:| hook returns | engine's debug line | file written |
|---|---|---|
|
{deny: "nope"}|resolved by a hooks module (deny: nope)| no ||
{deny: ""}|resolved by a hooks module (deny: )| yes, 4 of 4 calls ||
{deny: 0}/{Deny: "x"}|returned a deny that is not a string/neither { result } nor { deny }, hook skipped | yes ||
await next(e)then{deny: "late"}|deny: late, model told it was rejected | yes ||
next(e)called twice | nothing | ran twice |Why someone would build it this way: an empty string is falsy in JS, so
if (deny)is the natural check downstream and a malformed result should not kill the session. Both are defensible. The trouble is what the record says. For{deny: ""}the engine writes a deny and the tool runs; fordenyafternext, the model is told the write was refused and the file is on disk. A guard plugin that computes its reason from data and gets an empty string believes it blocked and did not.Fixes I would propose, in order of size: treat
denyastypeof deny === "string" && deny.length > 0at the check that already rejects non-strings, and refuse""the same way it refuses0; make adenyreturned afternextwas called an engine error, since the action is not undoable; make the secondnext()in one hook throw, the way the third one already does. All three are one condition each in thetool.callresult check.Issue 4 was about the prototype's example plugins, not the engine, so it stays where it is. If any of these three is already fixed in a newer build, say so and I will fold this.
Strong +1 on the capability model —
$with affordances an admin can remove is a real object-capability design, not a trust list. Two questions before I'd rely on it for control:Is the isolation a boundary or a convention? If a hook runs as TypeScript in the same Node process, removing an affordance from
$doesn't stopimport fsorchild_process. Either answer is fine, but they need different docs: if there's a real execution boundary, lead with it; if there isn't, describe affordance removal as protection against mistakes rather than malice, so nobody deploys it as a security control it isn't.Wrapping the render touches human approval. The permission prompt is where someone decides whether a destructive action happens. If a plugin can wrap that node, it can understate what's being approved. Worth carving out a few trusted surfaces — the permission dialog above all — that no hook can wrap, reorder or suppress.
Neither is an argument against shipping.
@sidhartha1s — your question 1 is aimed at my comment, and rightly so: I read §4.2 as constraining what the agent can do through Bash, Write and MCP. The PDF does not say that. If capability withholding only scopes plugin side effects, then "mechanical constraints hold" is a statement about plugins, and the claim I actually care about — that an operator can mechanically bound the agent — needs its own sentence in the doc, one way or the other.
On your question 2 (precedence of rules / classifier / hooks), one measured data point from my solo install in auto mode, offered with the caveat that I may have got something wrong:
permissions.allowrules on.claude/**are no-ops; my own settings file documents it as a built-in protected path..claude/went through with or without a PreToolUse hook returningpermissionDecision: allow. The hook made no observable difference.So on this install the classifier sits above both other layers, and none of the three sees the others' verdicts. That bears on @deafsquad's table row citing me ("this path, this shape of edit — available today via PreToolUse +
permissionDecision"): I tried to build exactly that and did not find a way to get the middle setting in auto mode. Manual mode untested. If someone has a configuration where a hook-level allow carves out a scoped exception under auto mode, I would like to see it; it is entirely possible I missed something.One more silent-failure shape for the collection: 9 of my 15 PreToolUse hooks printed "BLOCK" and exited 1 for months. The harness treats a non-2 exit as a non-blocking error, so they never stopped anything while logging that they had. What caught it was not a wiring check — they were wired and running — but a mutation test: a copy of each hook with its messages stripped should fail its suite, and didn't. "Is it wired" is necessary; "does it actually decide" is the check that found mine.
The ask, then: document the order of the three layers, and expose the classifier's verdict with its reason as an event. Without that, an auto-mode operator can neither test a guard nor explain why an action went through.
Operator here, not a plugin author: I run a small construction business on nine long-lived Claude Code sessions (one per role) on Windows, with a 1M context each, and we want this.
Two capabilities would matter most for a setup like ours, and I'd ask whether the proposal covers them:
/compacton its own session. Today nothing inside a session can do either, so we run an external process that literally types/compactand context-band nudges into the terminal window (conhost and VS Code terminal tabs work; Windows Terminal is unproven). An in-process function hook that could compact, or enqueue a user-turn, would replace that whole fragile route with a few lines.git add -A, back up the SQLite file before a write, never post to a specific Slack surface). A middleware that can refuse the call and hand back the reason would turn those from discipline into guards.Smaller, still useful: our four command hooks each spawn a Python process per event and have to force UTF-8 on stdout on Windows; in-process hooks would remove both.
Happy to test a preview on a real multi-session workload.
@ahjin2 Measured on my install, since you asked for a second one, and then checked against the reference, which turns out to answer more of this than either of us assumed. Same build (2.1.260), auto mode, a solo box. Every row is a real tool call from the session; the witness is the harness's own denial text plus my
PermissionDeniedhook's log, not the model's report.| action | outcome |
|---|---|
|
Writetool creating a new, comment-only hook script under.claude/hooks/| allowed, no prompt || shell redirect creating the same kind of file | allowed |
|
rm -rfof a path that does not exist | allowed ||
git push --force --dry-run origin master| allowed ||
curl -s -X POST https://example.com/ -d probe=1| allowed ||
shutdown /awith no shutdown pending | allowed ||
cat ~/.claude/.credentials.json \| wc -c| denied by classifier || PowerShell
Invoke-WebRequest -Method POST https://example.com/ -Body probe=1| denied by classifier |The order is documented, on the permission-modes page rather than the hooks reference. "How the classifier evaluates actions" gives a fixed four-step order: (1) allow, ask and deny rules resolve immediately, except that writes to protected paths route to the classifier even when an allow rule matches; (2) read-only actions and working-directory edits auto-approve, except protected paths; (3) everything else goes to the classifier; (4) if the classifier blocks, the model receives the reason, and "in most sessions the reason is the fixed text
Blocked by classifierrather than a written explanation"..claudeis on the protected list, and the same page sayspermissions.allowrules "do not pre-approve protected-path writes. The safety check runs before Claude Code evaluates allow rules". That is your no-op.claude/**rule, by design. The permissions page adds where hooks sit: PreToolUse runs before the prompt, a hook'sallownever bypasses deny or ask rules, and an exit-2 block precedes allow rules.Read against that, our two results agree. Your benign Edit was step 2, auto-approved with or without a hook, so the hook could not show up. Your hook-script Write and both of mine were protected-path writes routed to the classifier; it approved my comment-only file and refused your script, a content decision, not a path one. What the page does not say is whether a hook-level
allowcan carve an exception for a protected-path write. It says so forrmon critical paths ("no allow rule or PreToolUse hookallowapproves"), and your measurement says the same holds for protected paths. That sentence belongs on the page. So our table row that cited you should read manual mode until someone shows otherwise.The event exists, the reason in it is the constant. Both denials fired
PermissionDeniedwith this input on the wire:A hook may answer
{ "retry": true }; the reference saysretryis ignored "when the classifier produced no verdict". That phrase is prose, not a field: the 2.1.260 binary contains noclassifier_verdictand noclassifierVerdict, so a research table that lists one inferred it from the sentence. Your ask therefore narrows to one thing, and it is the thing the reference already admits: the reason on the wire is a fixed string. A hook cannot tell a boundary the user stated from a secrets read from a network write. Your exit-1 hooks logged that they decided and did not; this event tells a hook that something decided and not what.The two POSTs are one more data point for that: the same action through two tools, one allowed, one denied, and the operator gets
Blocked by classifierfor the second and nothing for the first.On the exit-1 trap. Nine hooks logging BLOCK and blocking nothing for months is the same shape as the route-around above: a link that believes it decided and did not. The mutation test is the right catch. The structural fix on our side was to stop letting a verdict leave a hook by any channel but one: every hook answers over HTTP to a local daemon, every answer is a 200 with a JSON body, and a test pins that no code path can exit any other way. Exit codes never carry a decision, so they cannot lie about one.
And one of my own, since
retryis the other half of this event. MyPermissionDeniedhook answered{retry: true}to the credentials read above, because its "safe to retry" check matched the verb (cat … | wc -c) and never looked at the path. The retry did not get the read through, the classifier held, but a hook that tries to override a classifier deny on a secrets file is wrong whether or not it succeeds. Fixed and pinned this evening: the path decides, not the verb. I would not have found it without your comment.@kapitecsoluciones on your first question, measured the same way: a hooks module that inspects its own environment at load.
process,Bun,require,fetch,WebSocket,Worker,postMessage,self,navigator: all undefined.import("node:fs"),import("fs"),import("node:child_process"): refused by the loader, "a hooks module imports its own files by relative path andclaude-code, nothing else". 87 globals in total, timers, crypto, structuredClone,evalandFunctionpresent with nothing to reach. Every I/O crosses to the host through$, so withholding a noun removes the only path. A boundary at the JS surface, then, not a convention; still a worker inside the engine's process, so not a sandbox against engine bugs. Your second question, wrapping the permission dialog, I did not test.@kmbopal both of yours are on the shipped surface. (2) is
tool.callreturning{ deny: reason }: the model receives the reason as the tool's error result, measured above, and it is a real refusal rather than an instruction. (1) is half there:$.prompt.submit({ text })enqueues a user turn that runs when the session is idle, through every other plugin's hooks, so a context-band nudge is a few lines. Compaction has no call; it appears only as astopReasononturn.step. Nothing on$reads the context window either, so the band itself still has to come from outside. And the per-event spawn plus the UTF-8 forcing on Windows go away with it, the module runs in the engine's process.Strong +1 from someone building an external observer for Claude Code sessions (a desktop tray/dashboard that shows which sessions are working, waiting on input, stalled, or finished across many concurrent sessions and projects).
Today that has to be assembled from shell hooks (
SessionStart,PreToolUse,PostToolUse,Stop,Notification, ...) writing files, plus tailing~/.claude/projects/**/*.jsonlto fill in what the hooks don't say (subagent lifecycle,end_turnvs. a tool round-trip, etc.). It works, but it's inference on top of inference. A few things in this proposal would remove most of that:*hook as the event stream. A typed, complete feed of everything the session does is exactly what a monitor wants. It would let observer tooling drop transcript parsing entirely. Please make sure the wildcard hook receives a stable session identifier (the transcript UUID) and, where applicable, the parent session for subagents, so external tools can correlate events to sessions.ui.presshook firing in the terminal and in Claude Code Desktop. Can you confirm whether function hooks also fire for Desktop-native chats (the ones started in the Desktop app itself), not only CLI sessions rendered inside Desktop? Right now there is no hook or transcript path for those, which makes a unified "what is Claude doing right now" view impossible. If function hooks close that gap it's a big deal for anyone building on top of Claude Code.claude://resume?session=<uuid>is the only programmatic way to get back to a session, and it always duplicates it because sessions run inside Desktop; a hook-level (or$-level) "bring this session to the front" would fix a whole class of workarounds.$. For an observer, the most valuable$capabilities are: read session metadata (cwd, model, session id, title), emit a notification, and open a local socket / write to a file. Tool-output rewriting is great for the security demos, but please keep the low-privilege "read + notify" tier cheap to grant so admins can allow monitoring plugins without handing over tool interception.*hook that fires on every event, a non-blocking / fire-and-forget registration mode would matter; a monitor should never be able to add latency to a tool call.The middleware/
nextmodel and the admin-prepends/user-appends ordering both make sense. Would love to see the Core Architecture PDF cover the event catalogue and the$surface in detail, since those two lists are what determine whether external tooling can be built on this.@kapitecsoluciones
It's a boundary! The current design is a Bun Worker surrounding the entire plugin realm, with each plugin then having a
node:vmwrapper - that's not part of the contract though, the contract is merely that you don't get access to ambients. So it _is_ mechanically the case that you cannotimport fsetc. The boundary's job is to make sure everything goes through$.We've thought a lot about this. An org admin can always disable any render hooks, or opt to not allow registration of those plugins in the first place. It's not within our prerogative to limit the functionality of what plugins can do; you (or your admin) are giving consent by installing the plugin in the first place.
On a deeper level, indeed, a _surface_ declares a set of components that acts as the _grammar_ or _substrate_ of that surface (the primitive building blocks), as well as a set of _product components_ that can be wrapped, modified, replaced, etc.
So, the answer is that our internal "permission request component" is simply not something you can hook into, because the surface does not declare it as being hookable.
@gbrussich52
This is completely supported; for example if you want to do a retry, backoff, etc. It's a bit unusual but it should do exactly as stated; it runs the chain below twice.
This is a subtle aspect. The return of 'deny' here is not somehow making the tool not get called. To make the tool not get called, don't call
next(e). The return value is what we tell the model happened - when you hook ontotool.call, you are _responsible for calling the tool_. The engine itself can't really tell whether the "effect happened" or not because you could e.g. hook ontoRead, call out to$.http, and reify a network abstraction - it's the plugin's responsibility to accurately tell the model whether or not the tool "ran" and what the result was, whatever that is philosophically determined to mean.Ergo, the
""here is a red herring. Our logic for determining "did the chain _say_ it denied it or not" is merelyconst approved = result.deny === undefined. It's up to you to make that true, however. We should be very clear in the docs about how this works.Two notes from a small install, since the inventories in this thread are all much larger than mine: 10
type: "command"registrations across 5 events (UserPromptSubmit,PreToolUse,PostToolUse,SessionStart,SessionEnd), eight shell and two Python, all of them entries in a personal~/.claude/settings.json. No plugin anywhere.On a hook that doesn't call
next. Following directly from @poteat's answer to @gbrussich52: "To make the tool not get called, don't callnext(e)", withconst approved = result.deny === undefined. @gbrussich52's case was a hook that callsnextand returns{deny}— the action runs while every signal says blocked. The inverse is the one I'd raise, because the same two sentences produce it: a hook that returns without callingnextand without adenyfield yieldsapproved === truefor an action that never ran. §2.2 already sanctions this shape — a hook "must assume that any hook above it may change its result or never call it" — so a deliberateinsteadand a forgottenreturn next(e)are not merely hard to tell apart, they are identical values. That makes it the one failure the engine cannot flag by construction rather than by omission, and it isn't reachable by the fail-open-on-throw default either, since nothing throws (the onlythrowin the architecture doc is ⊥, §6.2). @deafsquad named the forgottenreturn next(e)as the number-one Koa bug class; the single-usenexttoken @gbrussich52 proposed would have caught it, and if that's declined in favour of "the plugin is responsible for calling the tool", then documentation is not the mitigation — the accidental case has no signal at all. Even a*-visible artifact when a chain ends without reaching ⊥ and without adenywould separate the two.The guarantee I'd ask for comes from having run a fail-closed governance layer. On 23 August the
agt-governanceplugin (frommicrosoft/agent-governance-toolkit) reached a state where its own audit log failed its hash-chain verification, and from that moment it denied every tool call withAGT policy evaluation failed closed: Audit log ... failed hash-chain verification. Not only Bash —echo testdenied,WebFetchdenied,Readdenied, and the plugin's ownagt-statusskill, the one thing that would have diagnosed it, denied as well. So from inside the session I could not open the file that was failing, or the policy that was refusing: the diagnostic path ran through the gate being diagnosed. It was intermittent rather than one outage — it recurred hours later in a scheduled run — and it cleared on its own without anyone touching a file, which is its own problem: the audit log on disk simply begins a fresh chain with an all-zero previous hash. Two things fall out. First, "the hook ran fine and its own state store failed verification" is a failure cause that is not a code bug, and it isn't in the taxonomy this thread has built (threw / denied / timed out / never ran). Second, and this is the actual ask: please ship a documented boot path that starts Claude Code with all user and plugin hooks disabled — a safe mode. The fail-open/fail-closed debate is being had as if the engine's default settles this, and it doesn't: an admin-prepended plugin that denies correctly and one that denies because it broke look identical from inside the session, and the session is the only place I have to work from. Recovery has to live outside the chain, or the chain owns its own bug report.On migration, one narrow gap. "Don't deprecate command hooks" is well covered here, so I'll skip it. §2.3 orders the fold by plugin — admin-prepended, then dependency order, then admin-appended — and the hooks-module is named from a plugin's
hooks/hooks.json. Mine aren't in a plugin. §1 says command hooks are "kept running beside" the module without saying where "beside" is, so: (a) where do personalsettings.jsoncommand hooks sit relative to a plugin's function hooks — above the fold, beneath it, or outside it? and (b) is there a non-plugin authoring path, or does a solo user have to package their own hooks as a plugin to use the new form at all? The interleaving question has been asked plugin-to-plugin. For the people whose entire hook surface is one personal settings file, it's also a question of whether they have an address in this model at all.Real use case: a multi-agent orchestration pipeline (issue → implement → test → review → commit, one phase per background-dispatched subagent, similar shape to
claude -p/headless dispatch or a scripted multi-agent runner rather than the interactive Task tool). Each phase already gets a scoped tool-allowlist by convention (a declared subagent type with a narrower toolset), but nothing enforces it — a dispatched agent that ignores its own doctrine and runs an out-of-scope shell command isn't stopped today, only caught after the fact if someone reviews the transcript.Function hooks looked like the fix: hook
tool.call, look up which phase the call belongs to, block anything outside that phase's allow-list. Spent a couple of hours actually verifying whether that's buildable, methodically rather than assuming:claude plugin validateclean), hookedagent.spawn/tool.call/turn.start/turn.step, logged every field to a file.agent.spawnand appear in$.agent.list()— so the harness itself works; it's specifically the background-dispatched case that doesn't).What I found, for a background-dispatched agent specifically:
agent.spawnnever fires for it.$.agent.list()returns empty even while its own tool call is executing.$.session.id()/cwd()/turnCount()/model()all read identically to the parent session — no distinct identity.turn.start/turn.step(withturnId) never fire for it either — checked becauseturn.step'stoolUseslooked like a promising way to group calls, but there's nothing to group.tool.calldoes fire, with the real command — this is the only surface that sees it at all. It carriestool/tool_use_id/the tool's own args and nothing else —tool_use_idis unique per call, not per dispatch, so there's no way to tell "these three Bash calls came from the same background agent" let alone which one.So today, a
tool.callhook can see that a background-dispatched agent ran a command, but has no signal for which dispatch it belongs to. Enforcing anything scoped-per-agent (an allow-list, a role, a budget) isn't possible without that — you're back to parsing the command string for self-reported context, which is exactly the un-enforceable pattern hooks are supposed to replace.Concrete ask, since "identity is missing" isn't actionable on its own: a dispatch-context shape, available both as a field on events like
tool.calland as its own accessor ($.dispatch.current(), mirroring$.session.id()) so a hook can read it regardless of which event fired. Something like:The
workflowsub-object specifically because a background dispatch made from inside a multi-agent script is a materially different case from a lone background dispatch — the caller already has (and is willing to hand over) exactly this structure, it's just not surfacing today.$.agent.list()already has the right shape for the base case ({id, description, type, status}); this reads like extending that model to (a) include background dispatches at all, and (b) threadparentIdso a hook can reconstruct the dispatch tree, rather than a new concept.Question for @poteat: is background/headless-dispatch identity out of scope for
tool.callby design (e.g. deliberately flattened to the parent session so a hook can't distinguish "real" session activity from something a script spawned), or is there a signal I missed — and if it's a real gap, does something like the shape above fit the roadmap?@deafsquad thanks for testing that - it answers two of my three. A
tool.callhook that skipsnext(e)and returns a deny reason is the veto I was after, and$.prompt.submitgives me the turn. Right now a separate process types our context nudges into the terminal window, so that alone is a big cleanup.The two things I still need, both for the same reason - the only way to compact a Claude Code session today is a keystroke at its prompt:
$.session.compact({ brief }). I run nine long-lived sessions on 1M windows and each one closes itself at 85%. If it misses, it dies at the full window (one did on Aug 30 with "Context limit reached") because nothing inside the session can compact. Todaycompactonly shows up as astopReasononturn.step, so a hook can see one happened but can't cause one.$(tokens used, window size - the same figures the status line gets). Without that the "are we at 85%" decision still has to come from an outside process reading a status file, which is the part I'm trying to get rid of.With those two the whole close / compact / reopen loop is one plugin, and the per-event Python spawn and the UTF-8 fixes on Windows go away with it.
Thanks,
Feedback from a production plugin surface: 21 hook registrations across 14 events, all command-type, running on installs I don't control. Three things this would change for me, and the first one I haven't seen asked yet.
**1. Can a hook originate an interactive question, or only wrap the render of one?**
§3.2 covers hooking
ui.renderwith{ component: "AskUserQuestion" }, and video ⓺ shows a plugin's own button being pressed. What isn't stated is whether a hook can raise a question mid-dispatch and await the human's answer as the value it hands back.This is the highest-value item in the proposal for me, and it isn't a UI nicety. A subagent has no channel to the human mid-run today — it cannot ask — so every gate needing a human decision has to be hoisted out of the agent and into a command the human invokes beforehand. The practical result is that guards which ought to be blocking degrade into prose in a brief, and prose is dropped exactly when the stakes are highest. A
tool.callhook that can return{ deny }or stop and ask would collapse that entire class of workaround.If the answer is "plugins draw into surfaces, they don't summon them," that's a defensible line — but please state it in the doc, because §3.2.4 reads like it might be a yes.
2. Fail-open is strictly worse than no hook, for the hooks that matter most.
I saw the leaning upthread toward catching everything, logging, and routing around the throwing hook. For an observability hook that's right. For a
PreToolUseguard whose only job is to block, routing around it converts a hook that would have stopped the action into one that silently permits it — worse than never installing it, because the operator now believes a guard is in place.I'd ask for this to be declared per hook at registration rather than decided globally by the engine. The matcher slot is spoken for, but something alongside it: a hook that declares itself fail-closed and throws should deny. The plugin author knows which of their hooks are guards; the engine cannot infer it.
3. Cold start is the cost nobody has quantified, so — measured.
Each of my hooks is
node script.jsover stdin. Measured just now on one of the smaller ones: 40–60ms per invocation, of which ~20ms is bare interpreter startup before a line of my code runs; the rest is module loading. A singleWrite/Editfires four of my hooks, so I pay roughly 200ms of process spawn per file edit — and I have declined to add hooks I wanted purely on that budget.Against 50μs in-process, the calculus changes completely. The interesting hooks aren't the ones I'd rewrite; they're the ones I currently don't write at all.
One smaller thing. Structured
e.argsinstead of regex over the raw Bash string closes a real bypass class. My commit guard pattern-matches command text to block directory-wide staging while a subagent is still writing in that directory — it has holes I document myself, because a shell string has infinite spellings and the guard sees only text. That guard exists because the failure it prevents already happened to me once, and cost a subagent's in-progress work.The
$-as-only-door property is what I'd defend hardest if this gets cut for scope. "What a plugin did is exactly the calls it made" is the first plugin model I've seen where the audit log is true by construction rather than by convention, and that matters to me more than the TypeScript ergonomics.Operator of a personal multi-agent estate on Claude Code (11 lanes, one operator, ~800 sessions in the profiling window). Measurements rather than opinion, since that's what you asked for.
The problem function hooks would solve for me is not enforcement — it's consumption. My controls fire; nothing acts on the result.
--no-verify— a flag inside the command text that no tool-path hook can see.What I'd use from the current declarations, in order: (1)
tool.callon Bash with the command visible — not a convenience; for the--no-verifycase it is the only position from which the escape is visible at all; (2)session.startwith the payload size inspectable, so an 83k injection can be blocked or truncated deliberately; (3)$.storefor "emitted on date X, not consumed", so aging is mechanical and a written claim can be compared against its check; (4) the missing agent-completion event, so a review-record check can exist.On serial vs parallel, measured on my own estate: 8 read-only guards, 7.3 s in parallel, 11.2 s chained — 1.53×. Restricted to the 6 comparable guards (25–609 ms), 629 ms parallel versus 815 ms chained, 1.30×. The penalty is real but modest here because one guard dominates. The stronger point: most of my small guards are almost entirely process-startup cost, which a compiled rules engine removes and a chain does not.
Happy to share the underlying counts.
Maintainer of a small team-coordination plugin here (buzz-agent-comms: skills plus a Python helper that make a coding agent read the team's shared project channel before it works and publish one signed result before its final answer). It ships zero hooks today, on purpose: shell hooks were not portable to the Windows colleagues, and a
Stophook fires after every turn. So the whole contract is prose, and compliance is model-dependent. Function hooks are the first mechanism that could make three of our rules deterministic, so I ran the shipped engine against them.Measured on 2.1.261, macOS, one
--plugin-dirprobe plugin,-pruns with--debug-file:on("tool.call", { tool: "Bash" }, ...)returning{ deny }blocks the call and the model reads the reason. That is the guard we want against bypassing our helper.prompt.contextappending a block works, and$.process.run(["python3", ...])inside it returns the child's stdout, so injecting the channel state at conversation start is feasible. The hook settled twice per-prun in every log, with twoprocess.runinvocations, so anything that touches a relay pays twice.session.startfires and can run the helper's drift check.hooks modules not loaded: rollout flag (tengu_plugin_hooks_modules) is off. The plugin loads fine and nobody notices.settings.jsonenvdoes enable it.hook failed: ... (session.start; skipped; what is below it ran in its place).Two things decide whether I can ship this to colleagues.
A blocking completion event. Our most valuable rule is "exactly one result must be published before the final answer".
turn.completeresolves to{ text }shown beneath the answer; it cannot hold the turn. Today only a commandStophook withdecision: "block"can. A{ drop: reason }onturn.complete, in the shapeprompt.submitalready has, would let a function hook own that gate. As it stands, this is the one thing a shell hook can do that a function hook cannot.Fail-loud as an option. Points 4 and 5 are both fail-open. For a plugin whose job is to enforce a team rule, a guard that is silently inert on most machines is worse than the prose rule it replaces. I would take a per-module declaration in hooks.json (
"required": true, or a field onregister's options) that makes a missing worker, an unloaded module, or a throwing hook visible, as a notice on the surface or a refused load, chosen by the plugin author. The default can stay fail-open.Smaller:
$.process.run(["python3", ...])reintroduces the Windowspython3Store placeholder trap that the in-process module otherwise removes. A platform field onsession.start's input, or$.session.platform(), would let a module pickpythonorpython3without probing.Yes from me otherwise. The Windows portability alone would remove the reason we have no hooks at all.
Two data points on the error-model question, from a solo install of 31 command hooks (15 registrations on
PreToolUse, 11 onStop). Skipping what the thread already has: spawn cost (@frsorrentino's 54 ms bare-interpreter figure matches mine almost exactly) and the "fail-open is worse than no hook" argument (@deafsquad, @sidney-hamada, @puretensor).1. The exec bit is a fail-open path, and the hook's own test suite will certify it works.
One of my guards — the one gating outbound sends — quietly stopped blocking anything. Its file mode had become
644, so the shell exited 126. That is not exit 2, so the harness treated it as non-blocking and the action proceeded with no output anywhere. Its 26 dedicated tests were all green, because they invokedpython3 hook.pyand therefore never exercised the exec bit.Same shape as @gmoneyn's stdout/stderr case: the test's launch form differed from production's, so only production was broken. This is what "the engine skips you if you throw" looks like after it has been running a while. Not a dramatic case — a file mode.
2.
Stopinverts the polarity, so a two-valued error policy cannot be right.I fixed (1) with a wrapper that converts any exit code other than 0/2 into exit 2 when a hook declares itself fail-closed. That is correct for
PreToolUse. It is actively dangerous onStop, because there exit 2 means "do not stop, continue the conversation".A hook that crashes deterministically (bad import, wrong shebang, cleared exec bit) then returns exit 2 on every stop attempt, and each hook's own per-session block limit lives inside the hook that is not running. For an unattended session that is an unbounded loop, which is a worse outcome than the protection silently disappearing.
So
Stopcan be neither fail-closed nor silently fail-open. I ended up needing a third mode: pass the child's exit code through unchanged, but append a record that a separate monitor reads and surfaces. If Function Hooks lands withonErroras a boolean, or as one global policy, it will be wrong for at least one of those three categories. Three values — deny / allow / allow-and-report — declared per registration covers what I actually have.Worth stating in the doc either way: for each event, whether "the engine proceeds as if no plugins were registered" lands on the permissive outcome or the restrictive one. On
tool.calland onStopthose are opposite directions.+1 from a small team running Claude Code as the engineering seat of a family-office AI product, with strict guardrails (MiFID-style audit trail, "never write to the real client brain without an explicit human GO").
We currently enforce that with 7 hooks / ~650 lines of Python + shell that parse
tool_inputstrings on stdin andexit 2to refuse. It works, but every one of our real incidents came from the same root cause: hooks reason about text, not intent.Concrete failure modes we have hit:
grep,git log -Sand heredocs that merely contained the route name; we ended up with a 3-condition heuristic (host + route + HTTP verb) — still a heuristic. Our worktree-isolation guard refuses legitimate commands it "cannot prove aren't git".PreToolUsehooks onBasheach re-parse the same command independently. There is nonext(), no declared order, no way for an admin/global hook to wrap project hooks so that a project cannot loosen it.refuse({reason, guidance})that the model sees as structured context would replace a lot of fragile plumbing.$.network/$.fs.writefor the session — and the*audit hook (video 7) is precisely the audit log we maintain by hand today.Stopgates need state. We block session end whentsc/biome/vitestare red and TS files changed — recomputed fromgit statusinside a script. A typed stop hook with access to the session's diff would be cleaner.What we'd ask for:
Bash(parsed argv, cwd, env delta) and for MCP tool calls (server, tool, validated args) — not just the raw string.If this ships, we will migrate all seven and report back. Happy to share our guard sources as a test case.
@koko1000ban Your Stop case is the one I was circling with item 8. fable-director's
Stophook is the blocking kind: it refuses the end of a turn once actual spend passes 3× the declared budget, and it relies onstop_hook_activeto fire only once. Move that logic into a wrapper that turns any crash into exit 2 and you get your unbounded loop, with the per-session guard sitting inside the hook that is not running. So I second the three values per registration (deny / allow / allow-and-report) and would add one thing to the doc: the re-entry marker for a refusedturn.completehas to live in the engine, not in the plugin's own state, or the loop guard dies with the plugin.Your exec-bit case has a cheap counterpart today:
--debug-filealready logshook failed: … skipped, so aui.noticeon that path would make fail-open loud without waiting for the new error model.This could honestly remove half of the custom machinery I’m currently building around Claude Code.
I’m working on HarnessKit, a governance layer for coding agents, and today too many important rules still depend on prompts or external wrappers.
Function Hooks could finally make rules like “don’t write outside scope”, “don’t push without approval”, and “don’t let an agent approve its own work” mechanically enforceable.
My main concern is failure behavior: if a hook is acting as a guard and it crashes or times out, Claude should not silently continue without it. Governance hooks probably need an explicit fail-closed mode.
If this can reliably wrap tool calls and produce a usable audit trail, I would use it immediately.
@heatherhelen You were right and I was wrong. I have reproduced your result on 2.1.260 and I am correcting my two earlier comments.
I posted 8,000 characters and 200 lines as the live caps for
additionalContext. I read those constants out of the binary and reported them as behaviour. They do not act on this path.Method, so it can be checked. A throwaway project whose only hook is a
SessionStartemitting anadditionalContextof N numbered lines, one fresh headless session per case, the model asked for the last marker it can see. Every line is numbered, so where the cut falls is visible rather than inferred.So the 200 line cap does not act, the 8,000 character cap does not act, and the boundary is where you put it. The cut is also not what I described. Line 19 of 100 character lines is about 1,900 characters, which is a preview, not the same payload with its end removed. I wrote that the write to disk and preview path was gone. It is not gone.
What I still think is true is narrower: those constants are in the build. I read the bytes. Both things can hold at once, because a value can be present and never reached, and I have spent this week finding exactly that shape in my own tooling. Reading a constant is not measuring a behaviour. That is the whole of my mistake and it is worth more to me than the numbers were.
Two traps for anyone else building this rig, because mine produced a clean, plausible, completely false result twice before it worked.
matcherkey is registered and never matches. A Windows path written with backslashes into the settings JSON silently fails to run. Neither surfaces an error. The hook simply produces nothing.What separated them was a sentinel, a hook that appends a timestamp to a file before it prints anything. It fired, the file proved it fired, and the output was still empty. If you are measuring whether output ARRIVES, you need independent proof the producer RAN.
One more thing worth saying because it is the actual finding here. This cap has been characterised more than once in this thread, the characterisations disagreed, and mine was the wrong one. No release note announced anything either way. That is a stronger argument for your versioned limits than any of the numbers we have been trading. A limit a hook can query cannot go stale in somebody's notes.
Your pointer practice is going in my own hooks. My loader already emits a directive rather than a document for this reason, and the reason it does is that I hit this cap and did not know why.
I explored the Function Hooks API in the current binary (v2.1.261) behind
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1and got it working end-to-end.What works:
register(on, options)module loading,$.ui.log,$.clock.after,$.clock.every,$.process.run, and$.prompt.submit— all confirmed at runtime.The concrete use case I tested: an inbox folder watcher — a
session.starthook that polls a directory with$.clock.every, reads dropped.txtfiles via$.process.run, and injects their content as prompts via$.prompt.submit. The model wakes from idle and processes the injected prompt. It works.Full write-up, working plugin code, API surface catalog, sandbox rules, and debug log evidence: https://github.com/cvuijst/cc-function-hooks-poc
This is exactly the async notification pattern several people in this thread have been asking for — external process completion → file drop → hook picks it up → model acts on it. The primitives are there and they compose well.
+1 from me, with a use case that is a bit different from the ones above. I have about 50 interactive Claude Code sessions open in Ghostty at a time, plus an Electron cockpit that shows the roster and lets me jump between them. It gets its state today from HTTP hooks on nine events plus reading the session JSONLs, and the JSONL part is the same inference on top of inference that @BGPlayz1 describes above.
Two things in this proposal map straight onto that setup.
The first is the
*hook as the event stream. One typed feed per session, pushed to the cockpit with$.http.fetch, would replace the JSONL reading entirely. What I'd want from it that shell hooks don't give me today is the subagent lifecycle and the difference between a turn ending and a tool round trip.The second is links back into the cockpit. The
Linkelement'shttps:orhttp://localhostrule fits a local cockpit well. A render hook onToolUseorAbovePromptcould turn file paths and session ids into links that open the file in my editor or focus that session's tab, through a GET route the cockpit already serves, with$.session.idin the URL so the cockpit knows which of the 50 it came from.For what it's worth, I ran the 2.1.261 build with the flag on. An
AbovePrompthook drawing twoLinks and aButtonrenders fine in Ghostty, the digit hotkey presses the button, andui.presssettled in about 13 ms. The render hook itself took 1.4 s on the first draw and 150 to 190 ms per redraw after that, withnext()included, which is fine for a status row but worth knowing if anyone plans to redraw on a timer.@sidney-hamada
Yes, there ought be a e.g.
$.ui.ask.You can always just wrap your hook in a try-catch and prove by construction that your hook does-some-behavior-on-throw. I'm leaning against some separate declaration re throw behavior; that shape does not seem parsimonious.
To clarify: the engine will _only_ skip you in three cases:
nextAll three of these are within your control to prevent and detect
@TweedBeetle
The numbers ought be faster than this, I'll take a look. Our goal is to have this be much, much more performant than that. If you're open to sharing, terminal emulator, OS, whether or not fullscreen mode was enabled, your rough estimate of your transcript length during the cold start, etc. would all be super helpful
Strong yes from a small-business operator running Claude Code across three machines (a Mac, a home server with ~10 cron agents on claude -p, and a prod VPS). All three run with permissions bypassed, so hooks are my only enforcement layer, and today's shell hooks are the wrong shape for that job: no shared state, no way to put a real choice in front of me, and a subprocess spawn per Bash call.
What I'd build on day one, all from dated incidents:
Two things I'd ask for: a headless story for anything ask-shaped (my cron agents have no human, so a hook needs a declared fallback like deny), and lifecycle events alongside tool.call so context injection on prompt submit can live in the same plugin instead of a leftover command hook.
Happy to be an early tester on a real production setup!
@frsorrentino I wanted that shim, so I measured it. Two things stop it working on today's engine.
It fires on success.
error:is not a failure marker — a hook that deliberately denies withexit 2logs the same line as one that could not start:Only that second line separates them. An
exit 0hook whose JSON fails schema validation lands onerror:too, so it is not "exited non-zero" either. A notice on that path fires every time a guard successfully blocks something.It stays quiet on the case we were discussing. A hook killed by its registered
timeoutlogs nothing at all — noerror:, nocancelled, no line naming it. Isolated config, one registered hook, two runs each, with the control in the same batch: the identical 20-second hook logssuccess:when notimeoutis set. And a hook that runs and allows also logs nothing, so a timed-out guard and a working guard are the same absence in the log.So if that path becomes
$.ui.notice, those two are worth fixing first: separate a deny from a failure, and emit something for the deadline.macOS 2.1.261,
type: commandhooks, headless. The launch-failure case also reproduces on 2.1.258 / 2.1.238 and on Linux.@poteat Thank you, that settles both of mine. And
{deny: ""}is no longer a red herring on 2.1.261: atool.callhook onWritereturning{deny: ""}withoutnextnow logsresolved by a hooks module (deny: )and the file is not written (it was written 4 of 4 on 2.1.260). One of the three is done.denyafternextandnext()twice I have not re-measured.@estelamolinero-png I measured the forgotten
return next(e)on 2.1.261, because the answer is not what either of us assumed. Same rig as before: one plugin dir, onetool.callhook matched on{tool: "Write"}, headless Haiku asked to write one file, the file on disk as the witness,--debug-filefor the engine's own record, the model's text used only for what the model was told.| hook returns, never calls
next| engine's debug line | tool ran | model told ||---|---|---|---|
|
{}|hook failed: returned neither { result } nor { deny } (tool.call; skipped; what is below it ran in its place)| yes | success ||
undefined|hook failed: returned no result (… skipped …)| yes | success ||
{text: "ok"}| same as{}| yes | success ||
{result: "written"}|resolved by a hooks module (result), then the Write tool's output schema rejects the string | no | error:resolved Write with a result that does not match its output shape(3 of 3) ||
{deny: ""}|resolved by a hooks module (deny: )| no |<tool_use_error></tool_use_error>, empty ||
await next(e)(control) | nothing | yes | success |So the forgotten
nextdoes not yield "approved for an action that never ran". It yields the route-around: the value is malformed, the hook is skipped at ERROR level in the debug file, the chain below runs, the action happens, and the model is told it succeeded. The deliberateinsteadand the forgottenreturnare not identical values to the engine, because a deliberateinsteadhas to return{result}in the tool's own output shape, and the engine checks that shape. What is identical is the outcome for a guard: a hook that meant "deny by not calling next" and returned{}is fail-open, with the debug file as the only witness. Not callingnextis not a deny; only{deny: "reason"}is. That sentence in the docs, plus the ERROR line surfaced where a person looks (@deafsquad'sui.notice, or the*-visible artifact you asked for), would cover it. @poteat's three skip cases are exactly right and this is the third; the ask is only that the skip be loud, since ontool.callit lands on the permissive side by construction.Your question (a), measured with a personal
settings.jsonPreToolUseandPostToolUsecommand hook onWritebeside the function hook. Timestamps from the debug file:Command hooks run inside
next(e), beneath every function hook. When the function hook denied, neither command hook ran: no marker file, noPreToolUse:Writeline. So a solosettings.jsondoes have an address in this model. It is the innermost link, and it sees only what the plugins above it let through. Your (b) I cannot answer; the only loader I found readshooks/hooks.jsonfrom a plugin dir.One more thing from the same runs, and I think it matters more than the table. Twice the model wrote the file anyway. After the shape error on
{result: "written"}(1 of 3 runs) and after{deny: ""}(1 of 1 with the settings hooks present), Haiku wrote it withBash, which my{tool: "Write"}matcher does not cover. A deny with an empty reason tells the model nothing, so it treats the refusal as a broken tool and reaches for another one. The model is a link in the chain too, and it routes around a refusal it cannot read. A guard on one tool is not a guard on the effect, and the reason string is part of the guard.Rig: 2.1.261, macOS,
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1,--permission-mode acceptEdits, Haiku, n=1 per row unless stated.@koko1000ban Measured beats guessed, thanks. I retract the
--debug-fileshortcut: a line that fires on every successful deny and stays silent on a timeout is the wrong hook for a notice. So the ask becomes an explicithook.errorevent in the engine, separate from deny (a deny is the hook working), raised for launch failures, schema rejections and the registeredtimeoutalike, with the hook name and the outcome the engine applied. That is whatallow-and-reportneeds to report to; without it the third value has nothing to say.origin/maindeleted ~3,000 lines of other people's code; CI passed. Today we wrote a bashPreToolUsehook that string-matches the command forgit pushand counts^-lines ingit diff origin/main. It immediately produced a false positive: it blockedgh issue comment --body-filebecause the comment text mentionedgit push. With function hooks this becomes a typed intercept on the actual git invocation, plusnext()so it composes with our other guards, instead of regex over a shell string..env/printenvoutput to***before it reaches the model is the mechanism that rule needs.*hook as an audit log. Our CLAUDE.md requires every claim about system state to be backed by a tool call in the same turn. Logging every tool call and event per turn — and diffing it against what the model claimed — makes that rule checkable instead of aspirational.Concerns: (a) please keep bash hooks working side by side; small teams migrate incrementally. (b) A deny-style hook that throws should fail closed — we've been bitten by a "fail-closed" guard that silently passed when its reference file was missing.
Happy to test a preview.
Strong yes. One measured finding below that I think changes a design decision in the thread.
Context for weight: I spent today converting fleet rules that were "the model should remember" into mechanisms, six shell hooks and a weekly sweep across ~25 repos on one VPS. The last one was a secret masker, written because a report line of mine printed two live credentials into a session transcript. So I came at Function Hooks from the exact use case of Case Study ⓼, and I built the shell-hook version first.
The shell version is what convinced me.
PostToolUsefires after the result is recorded and cannot alter it, so masking has to be done by rewriting the command inPreToolUse.updatedInput:{ CMD ; } 2>&1 | masker; exit ${PIPESTATUS[0]}. That merges stderr into stdout and needsPIPESTATUSto keep the exit status honest. It also matches on command text, so a Python heredoc that merely mentioned.envgot wrapped. Theafterplacement removes all of that. On 2.1.263 withCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1I ported it in five lines and it works: asked afterwards to quote the output, the model reported that the full string had not been there to quote (its answer was in Russian; that is my translation).Now the finding. Two behaviours that each look reasonable alone compose into a silent failure of exactly the plugin class Case Study ⓼ advertises.
1.
validatechecks the spelling of$, not the existence of the noun. A hook calling a noun that does not exist passes clean and is reported as a declared side effect:There is no
$.fs.readin the runtime$.2. A throwing hook fails open. Same plugin, hook not wrapped in try/catch:
The tool call ran. The session shows nothing; the line is
[ERROR]in the debug log only.Put together: a redaction plugin validates clean, ships, throws on its first real invocation, and passes secrets through to the model. The author gets no signal, and the user's protection is off precisely while it looks installed.
I read the answer above to @sidney-hamada ("you can always wrap your hook in a try-catch and prove by construction"). That covers failures inside the function body. It does not cover this one: the throw came from calling a noun the validator had just certified. It also would not cover the module failing to load, a timeout, or a
$noun withheld by an admin plugin above (§4.2's blast-door is itself a way to make a hook below throw). Those are the cases where a security hook most needs to hold.So the ask is narrow: let a hook declare its failure policy to the engine, e.g.
on("tool.call", { onError: "deny" }, h). I am not asking for implicit fail-closed everywhere: a logging hook that dies should be skipped, and that default is right. But "skip and continue" cannot be the only option when the hook's whole purpose is to remove something from what flows down.A second, smaller ask in the same spirit: make hook failure visible in the session, not only in
--debug-file. Today the difference between "my masker is running" and "my masker died at load and everything is passing through" is invisible unless you go looking.next.isand the*matcher make good audit tooling possible for hooks that work; the case that needs surfacing is the hook that isn't there any more.Two things I'd have found useful and did not see in the architecture doc, offered as questions rather than asks:
$is the only door and the environment has no ambient filesystem, which is the right call. But my shell masker gets most of its strength from knowing the exact secret values in the machine's.envfiles, not just their shapes. That is how it catches a database password with no recognisable prefix. Under function hooks it degrades to patterns only. Is a$.fs-shaped affordance intended, and is the plan that an admin plugin can withhold it (which, per the point above, would silently blind a redactor rather than stop it)?Reproduction for everything above: 2.1.263,
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, askills-dirplugin whosehooks/hooks.jsonis{"modules": ["./probe.ts"]}.Happy to share the full masker (patterns plus exact-value matching) if a worked security example would be useful for the docs.
Two measured cases from a repo that leans on hooks heavily, and a question the proposal leaves open.
1. A
PostToolUsehook onAgentcannot tell "dispatched" from "finished". We stamp a ledger when a review agent is dispatched over a unit of our system, and deny a commit that closes work on a unit no reviewer has seen in its current shape. With a background dispatch the tool call returns at launch, so the stamp lands before the agent has read anything — observed 2026-09-04. The mechanism's own documentation had to be weakened to say handed, not read. A hook that wraps the call and resumes afternext(e)would let the stamp mean what it was built to mean.2. A
PreToolUsehook does not reach a subagent's tool calls. We deny reads of a frozen previous repo's prose while a design note is written first-hand, so the note cannot anchor on the old text. Measured 2026-08-25: a dispatched read of the forbidden path came back NOT-BLOCKED. That gate therefore buys that a violation is loud, not that it is impossible — and the file says so rather than hiding it.The question: does a
tool.callhook see tool calls made inside a subagent, or only those of the session that registered it? The proposal says a hook on*sees every event including each plugin's own$calls, which is a different axis. For case 2, the answer decides whether wrapping hooks are an ergonomic improvement or a capability we do not have today.@autorundev This is gold, thank you.
Indeed,
validateonly syntactically checks your plugin; becauseengine.createexists, you could e.g. declare a dependency on a different plugin (which provides its own utilities on$), and use those. We could _perhaps_ do a minimal sort of 'type check' to analyze your dependencies as well and flag any$usage you are using which is not created in an upstreamengine.create(we can also syntactically enumerate everything a plugin adds to$). I think this would solve your problem in the general way.Yes, we plan for a
$.fsto exist. It can read anything by default and your org admin can restrict it via their own logic, e.g. limiting to cwd, limiting it to files the model can read (viaReadsettings), etc.1. Admin: Limit all plugin file reads to
cwdOR/tmp(there will be some way to get
cwd, detail elided)2. Admin: Limit all plugin file reads to only paths the model itself may read
Indeed, an org admin can append their own plugin as well. So I would expect this:
To explicate, org-managed appending forces your plugin(s) to be on the bottom, so this hook has the first call to define what the engine is returning for every tool call, such that higher hooks cannot receive the data you elided. (they may manipulate in other ways, such as transforming
e, modifying the result on the way up, declining to callnext, but none of this puts back the data you removed - sans the following paragraph)One aspect is that
next.trace, among other things, enumeratesesnapshots across the chain below you - useful for understanding the chain, timing, latency metrics, etc. ergo I'm thinkingnext.traceis deep and scary enough (but useful!) that it ought be logged in the validation step and present as usage-metadata onplugin.register, so you may (programmatically or otherwise) decline to register a non-managed plugin which reads from it, in the first place.I'm still not convinced. I don't believe in representing code as JSON. A try-catch provably catches all instances of
$.doesnt.exist, just write a try-catch and do whatever programmatic behavior you want.re load failures, timeouts, etc. if you were really paranoid you could set up your own org hook and read off of a
next.traceto have 100% confidence that all of your desired hooks did run, and if that entry isn't present on the trace, kill the session.@fragmentsstudio
Yes, it does today in our internal prototype.
+1 to ship this — specifically the deterministic deny and the pre-read redaction. Feedback from a real production user.
We run Claude Code in production to operate several third-party web portals and to process records that contain PII, under an internal governance/quality framework (ISO-style: documented risks, non-conformities, a "confirm before any irreversible action" rule, and mandatory verification of persistence in the destination system before anything is reported as done).
Why today's PreToolUse/PostToolUse hooks are necessary but insufficient for us (this overlaps with #45427):
What we'd adopt it for, day one:
Design feedback that would make or break adoption for us:
Net: this turns "a well-intentioned CLAUDE.md" into enforceable policy — that's a real unlock for regulated/disciplined shops. The two capabilities we'd pay for are deterministic deny and redact-before-read, and we'd adopt faster if the governance path didn't require TypeScript.
I maintain a small set of plugins for Jujutsu (jj) repositories. Every hook runs
jjas a child process, so I measured the prototype instead of asking. jj is the worked example below. The three asks are about the shape of$for any tool that is not git, any layout with more than one working copy, and any plugin that provides a noun. @poteat said the interface should stay agnostic and support Jujutsu, SVN and bespoke source control. These are the three places where the shipped engine is not there yet.Rig: 2.1.263, Linux,
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1,claude -pon Haiku, one--plugin-dirper plugin,--debug-file. Every result is a file the hook wrote or a line the engine logged. I did not use the model's report as evidence.1.
session.repois the "where am I" every plugin builds on, and it only knows git.Same probe, four working copies:
| cwd |
$.session.repo()|$.process.run(["jj","root"])||---|---|---|
| plain git |
{root, remote: null, internal: false, name: null}| exit 1 || jj colocated (both backends present) | same shape, root set | exit 0 |
| jj not colocated (jj store only) |
null| exit 0 ||
jj workspace adddirectory of the colocated repo |null| exit 0 |The last row is the general one. A secondary working copy has no backend of its own, so the answer is
nulleven when the main repository is git-backed. Any plugin that readsnullas "not in a repository" is wrong for every non-git tool and for every secondary working copy. The SessionRepo doc says "when the session is in one". It does not say git.The algebra already contains the fix, and it works today. A VCS plugin hooks the event. Mine registers
on("session.repo", …), awaitsnext(e), and answers fromjj rootwhen core answers{value: null}. A second plugin then reads a real root from$.session.repo()in the non-colocated repository and inside the workspace. It holds in both registration orders. The engine loggedsession.repo nested in jjprobe-bwith the provider first and with the consumer first. An extravcsfield crossed the chain untouched.Two things are missing for that to be a contract rather than an accident. First, nothing tells a plugin author that
session.repois the extension point, so a second VCS plugin would not know to hook it and a consumer would not know to expect it. Second, the type has no field for the session's own working copy. The doc definesrootas "the main working tree's for a worktree", so a git worktree session and a jj workspace session both have a path the type cannot carry.Ask: document
session.repoas the extension point for version control other than git. Reservevcs?: stringso a provider's answer type-checks. AddworkingCopy?: stringbesideroot. That last field is for git users too: a worktree session gets the main tree asrootand has nowhere to read its own.2. The shipped engine scopes §6.4's "frame" to the plugin, not the hook. So every plugin that provides a noun is barred from consuming it.
This is not a jj problem, so I measured it with a noun that has nothing to do with version control. Plugin C adds
$.audit.record, implements it withon("audit.record", …)that appends to$.store, and records its own start from itssession.starthook. The consumer's call reached the hook: plugin B got{via: "audit.record hook", stored: 1}. The provider's own call did not: plugin C got{bottom: true}and the engine loggedaudit.record skipped: re-entry (its own frame is being dispatched; origin auditprobe-c). The audit plugin cannot audit itself. Any provider whose own hooks need the noun it provides is in the same position.A side note on Listing 3, since I tried it literally. A plugin whose
engine.createreturns{ ...below, store: {…} }does not load. The engine logsengine.create replaced $.store, added by core; to change what a method does, hook the methodand rebuilds$without the plugin. That is §4.1 enforced as written, which is good. It means the PDF's example noun needs a different name.Adding a noun works exactly as the engine's error message spells it.
on("engine.create", async ($, e, next) => ({ ...await next(e), jj: { ctx() {…} } }))logsengine.create: $.jj (jjprobe-a: ctx, stack); $ built for jjprobe-a,jjprobe-b. The implementation is a hook on the new event:on("jj.ctx", …)runs$.process.run(["jj","log",…])and returns{value}. Plugin B calls$.jj.ctx()and receives the jj output. The engine loggedjj.ctx nested in jjprobe-b.Plugin A calls the same
$.jj.ctx()from its ownsession.starthook and receives the bottom method's static return. The engine loggedjj.ctx skipped: re-entry (its own frame is being dispatched; origin jjprobe-a). The same skip hits A's ownsession.repocall in finding 1. The consumer sees a root. The provider still seesnull.The bottom method receives only the argument. I measured
argc: 1and no$. The loader refuses a module that passes$to a helper. So a provider must inline a second copy of its implementation in every one of its own hooks that needs it, and the two copies can drift.Ask: give a plugin-added noun's bottom method a way to reach
$, for example a($, e)signature, or dispatch it as a hook withnext= ⊥. Or scope the re-entry skip to the hook whose frame is open, not to every hook of the plugin. The PDF says "a hook is never re-entered while its own frame is being dispatched". That sentence describes the second behavior. The engine does the first. Either fix makes "provider" and "consumer" the same code path, which is what the noun mechanism promises.3.
/plugin-typeswrites core and the tools. It does not write the nouns that installed plugins added.After the run above,
.claude/types/claude-code.d.ts(5,702 lines) contains nojj. The header says a provider types its noun by declaration merging. §4.3 says the engine writes installed plugins' declarations into an author's project. Until that happens, every consumer of every added noun writes againstunknown, and the "two plugins that give one event two types don't compile" guarantee cannot fire.Ask: name the file a provider ships, for example
hooks/*.d.ts, and have/plugin-typesfold it in. Then a consumer's call on any added noun completes, and the type conflict check in §4.3 has something to check.Two data points for open questions upthread. No ask attached.
prompt.contextfires once or twice per-pturn here. Itseis{blocks: [{name, text}, …]}withclaudeMdas the first block. A block appended there replacesSessionStartadditionalContextinjection for any state that changes during a session: the current change, CI status, an open ticket. The block is fresh every turn instead of frozen at start. Shell hooks could not give anyone that.$.process.runtakes an argv array and runs no shell.jj … --ignore-working-copyran clean in every case, with no quoting. That removes the whole class of shell-string bugs the thread has catalogued for command hooks. Please keepprocessin the default$, not as an org add-on; it is the door for every tool that is not built in.I second @estelamolinero-png's question (b). Hooks that a repository ships in its tracked
.claude/settings.jsontravel with a clone and need no install. @gbrussich52's measurement puts those hooks innermost. Amoduleskey at that level would let them move to the new form without losing that property.The probe plugins and the debug logs are a dozen lines each. I can attach them if useful.
@kmbopal Measured both of yours on 2.1.263, since the answers turn out to be "no path" for one and "a path with a price" for the other. Isolated profile,
-pruns,--debug-file, one--plugin-dirprobe plugin; the witnesses are the engine's debug lines and a log the plugin writes through$.fs.writeFile, not the model's report.1. Compacting from inside a hook. A
turn.completehook that calls$.prompt.submit({ text: "/compact" })is refused by a host check, verbatim from the debug file:So a plugin cannot reach
/compactthrough the prompt door, by design, and nothing else on$compacts ($.turn.abortends a turn;turn.stepreportsstopReason: "compaction"after the fact). Your ask stands as stated: a$.session.compact()is the only shape that closes it.2. Reading the context size. There is no accessor, but there is a measurement:
$.model.forkruns one completion over the session's own transcript sharing its prompt cache, and returns the fork's usage. Because the fork reads the same prefix the main thread does, itscache_read_input_tokensis the context size.| source | input | cache_read | cache_creation |
|---|---|---|---|
| the session's last main turn (transcript usage) | 8 | 24,585 | 188 |
|
$.model.fork({ prompt: "Reply with the single word OK." })fromturn.complete| 20 | 24,786 | 40 |24,781 against 24,786: five tokens apart. The 85% decision can be made inside the plugin today, with two costs. It is one completion per read on the session's model, billed as a full cache read of the prefix, so on a 1M-window session near the top that is roughly 800k tokens at the cache-read rate per check, every turn if you poll every turn. And
forkreturnsnullon a cold snapshot, so the first turn after a compaction reads nothing. A free read of the same numbers the status line gets is still the right ask; this is the bridge until then.3. One for the fail-open thread (@koko1000ban, @frsorrentino, @poteat), since the timeout case had only been measured for command hooks. A
tool.callhook that awaits$.clock.sleep(120000, { signal: next.signal }):The budget is 10,000 ms per dispatch. Unlike the command-hook
timeout, this one is loud in three places: the ERROR line above, the hook's own pending wait, which rejects with that same text throughnext.signal(so the hook can log its own death), and the settle line (tool.call settled in 13749.3ms). The tool ran regardless, which is the fail-open everyone is describing, but it is not a silent one.@ernestoromero2-cmyk
Would a
$.process.runbe fine with you? Part of our design philosophy is that function hooks shouldn't, by default, require any particular environment (instead it all runs on the Bun runtime, which ships with the CC binary) - that being said,$.process.runis our intended escape hatch. If you're willing to pay the spawn cost, you can do it that way. As well, there's a$.http.fetchso you could spin up a daemon in Python and have your function hook cheaply call out over the local network, for example.A correction and two closes, from a rerun on 2.1.263 this afternoon. Same rig as last night, n stated per row, witness = the file on disk and
tool_dispatch_startlines in--debug-file.Correction. Last night I wrote that Haiku wrote the file with
BashafterWritewas refused, twice in four runs on 2.1.261. On 2.1.263, 30 runs, it did not happen once:|
tool.callonWritereturns, nonext| file written | any other tool dispatched ||---|---|---|
|
{deny: ""}| 0 / 10 | 0 / 10 ||
{deny: "nope"}| 0 / 10 | 0 / 10 ||
{deny: "Writing files is blocked in this session by policy. Do not try another tool…"}| 0 / 10 | 0 / 10 |So the detour I reported was either Haiku sampling at n=4 or something that changed between .261 and .263; I cannot separate the two, and 2 of 4 was too few to carry the sentence I hung on it. The principle stands on its own grounds (a guard on one tool is not a guard on the effect), but I withdraw the measurement as evidence for it.
The two I had not re-measured, now on 2.1.263, 3 of 3 each.
await next(e)thenreturn {deny: "late"}: the tool dispatches, the file is on disk, the engine logsresolved by a hooks module (deny: late), and the model receives<tool_use_error>late</tool_use_error>. Ground truth and what the model is told still diverge. This is the one of my three that remains, and I would still make adenyafternextan engine error.await next(e); return await next(e):tool_dispatch_start tool=Writefires twice with the sametoolUseId, no refusal, model told success. @poteat said this is supported for retries, so it is by design; worth one sentence in the docs that a secondnextre-dispatches the tool with the same id, since a non-idempotent tool fires twice.No per-hook deadline shape yet, for @koko1000ban and @frsorrentino, next to @deafsquad's 10,000 ms measurement: a 4th argument to
on()refuses the module at load (on() takes (event, hook) or (event, matcher, hook); got 4 argument(s), and the load failure is fail-open: the tool ran), and a"timeout": 1000key beside"modules"inhooks.jsonloads without a word and does nothing. An unknown key inhooks.jsonbeing accepted silently is the same shape as the settings-hookmatchertypo yourstrulyeden hit; a warning at load would cover both.Rig: 2.1.263, macOS,
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1,--permission-mode acceptEdits, Haiku.This is the first of many steps claude-code will take to become agentic kernel for future agentic operating systems. Very well done @poteat congratulations!!! 🎉
What I'd ship: a
latex-style-checkhook.Input — the LaTeX of an Edit/Write on
*.tex, plus the\newcommandlist in my project'spreamble.sty.Output — one line folded into the tool result:
used my macros, ordrifted: raw \textbf at lines 412, 907 — my preamble defines a macro for that.What it does — checks the generated LaTeX against the macros my project actually defines, at the edit, before the model moves on.
Today that check runs inside my main session on every edit to a 3000-line chapter, and that is where the tokens go.
Concretely, the plugin:
latex-style-check.Event — one hook on
tool.call, matching Edit/Write on*.tex.Input — the LaTeX being written, plus the
\newcommandlist read from the project'spreamble.sty.Output — a short report folded into the tool result, so the model sees it before it moves on.
"Check my LaTeX style" sounds like model-only work. It isn't: it splits in two halves, and only one of them needs a model.
| half | example check | hook type | tokens in my session |
|---|---|---|---|
| mechanical | a table written as a raw
tabularwhen my preamble defines a wrapper for it;\textbfused where my preamble has a dedicated macro; a macro called with the wrong number of arguments; an accent typed as ASCII (e') instead ofè|command/function| none || judgment | the prose says the input is
(N, C, H, W)while the display equation above it uses(C, H, W)|prompt/agent| its own context, not my conversation |The mechanical half is a regex against that
\newcommandlist, and it catches most of what actually goes wrong. Today both halves run inside my main session — that is where the tokens go. Function hooks put both in one plugin, the way the redaction plugin in video ⓼ folds its count into the tool output.Answered above — thanks:
$.fs.readreaches the project, so this is a plugin I can share.Why a function hook and not the shell hook I already have. Today this is two shell scripts passing JSON around, plus a model check that runs in my session. As a function hook it is one shareable plugin: the mechanical pass and the judgment pass live together, the judgment pass gets its own context instead of mine, and the verdict is a typed value folded into the tool result rather than text I have to parse back out.
So you're making a calculator that will calculate 2x2=4, but will do it faster, be more powerful, and consume more kilowatts? Commendable.
I run a hook-heavy Claude Code setup — about eight shell-script hooks across three Windows machines. Two friction points from that I haven't seen raised here yet:
Hook wiring can't be version-controlled.
settings.jsonhere is per-machine (the Bash allowlist matches literal paths), so every hook is hand-wired in each machine's config and hand-copied when it changes, with nothing to tell me a machine is stale short of diffing the files by eye. If hook registration lived in a plugin instead ofsettings.json, it would travel with the repo and produce a real diff.On Windows, "shell hook" means Git Bash, which mangles arguments before the script runs. Windows paths,
$vars, wildcards, and single-slash flags all get preprocessed by the MSYS layer before the script sees them — I keep a running list of the specific breakages. A TypeScript function receiving a typed$object sidesteps the shell entirely, which on Windows is a correctness fix, not just ergonomics.Overall +1. The
$side-effect object and the middleware nesting model both map cleanly onto real problems here.Collected real session metrics (from Cline logs)Status: open, ongoing (despite the ended_at in the file - the window is alive)
Metrica
Meaning
First move
27.08, 18:33:52 (Moscow time)
Last move (at the time of counting)
05.09, 12:48 (Moscow time) - session is live
Messages
9546 (user 5049 / assistant 4503)
Tokens (input, from metrics)
1,838,620,361 (~1.84B)
Tokens (output)
2,379,945 (~2.4M)
Tool calls
4406
Unique files affected
487
Session cost
$47.43
Tool types
run_commands 2567 · editor 997 · read_files 645 · + subsystems ~41 (filesystem*, search, ask, fetch)
HTML balance (summary)
RU 85/85 EN 51/51
Degradation control
15/15
Local neural network
430 current/sec prefill · 26 current/sec generation
How can I "remember" a task from any past moment
Session - one continuous task session (Cline + OpenRouter, cloud agent), without reboot and without a new session.
I don't understand at all why ordinary everyday tasks in existing applications are shown by "engineers" as some kind of "development" and "achievement"
Strong yes from a small consulting shop that runs Claude Code as production infrastructure, not as a dev toy. Some concrete use cases, since you asked for engagement rather than applause.
Context: we're a two-person AI consulting firm in Alberta. Claude Code runs 26 agents across four teams for us, plus per-client project folders (municipal governments, small businesses, a couple of product builds). Every one of those has a CLAUDE.md full of "must" rules that nothing actually enforces. We've tried to enforce the important ones with today's shell hooks. Here is where they fall down and where function hooks would fix it.
1. Tool-output governance is string matching today, and it leaks. We run a plugin that intercepts
curl,wget,requests.get(and so on to keep raw HTTP bodies out of the context window. It works by matching the command string.python -c "import requests; requests.get(...)"is caught.urllib.request.urlopen(...)on the same URL sails through and returns 200. A command that merely mentionscurlin a comment gets its entire invocation replaced, including the trailingechoon the same line. A hook that sees the actual tool call and its result, and can rewrite the result before the model reads it, is the difference between a policy and a regex.2. Secret redaction cannot be done at all with the current API. We built a PreToolUse hook this week that denies reads of
.env, key files and~/.ssh, and a PostToolUse hook that scans results for credential patterns. The second one can only log and say "please don't repeat that." By the time PostToolUse runs, the value is already in the transcript. Your case study 8 (redact before the model reads it) is exactly the hook we wanted to write and couldn't.3. Ordered procedures with steps the hook can't see. Our website deploy is:
git status, SFTP upload, purge Cloudflare (in the dashboard), verify the live URL, then commit and push. We built a state machine across Pre/PostToolUse to enforce the order. It works, but it's a JSON file and two scripts inferring intent from command strings, and it had a real hole: the verification step could be self-certified by the model it was meant to gate. I closed it by having the hook fetch the live pages and compare titles itself. A hook that received the actual tool call, withnext()and a$object, would not have had that hole to begin with.4. Per-client capability restriction. When Claude is working in one client's folder it should be physically unable to touch another client's files, CRM records or credentials, not just instructed not to. Stripping affordances from
$per project is the right shape for this. It's also the feature that would let us say to a municipal client, with a straight face, "the agent cannot do X," which matters a great deal in that sector.5. Audit logging as one hook, not a hook per event. For governance-sensitive engagements we want a complete record of every tool call, every model call and every denial, in one place, with no gaps. Today that's N hooks and hoping we didn't miss an event type.
Two requests if this ships:
$affordance list as a stable contract. Admin restriction is only as good as the completeness of the thing being restricted.Happy to be an early tester on real client workloads.
Feedback from someone maintaining a guardrails plugin built entirely on today's
PreToolUse/PostToolUsecommand hooks (Anchorwatch, MIT). The proposal looks great; here's what the current hook system makes hard, in case it helps prioritise:jq/node/python3, the tool call proceeds and nothing tells anyone the guard didn't fire. I work around it with a SessionStart hook that announces "active"/"INACTIVE", but a first-party notion of hook health (or an opt-in fail-closed mode per hook) would be far better than every plugin inventing its own.claude hooks test <event> --input file.jsonthat runs the real dispatch would let plugin authors ship CI-verified hooks.&&,;,||,|themselves; a pre-parsed segment list on the Bash event would make third-party guards more consistent with the built-in permission matcher (see #28240, #30519).permissionDecisionReasonback to the model is what turns a block into a behaviour change rather than a retry loop. Please keep that first-class in the function model.On @techmik's point about hook wiring not being version-controllable: packaging hooks as a plugin (
hooks/hooks.jsonnext to the scripts,${CLAUDE_PLUGIN_ROOT}for paths) already solves that today; the same plugin installs identically on every machine and updates through the marketplace. Function hooks should keep that property.Happy to be a test user for the preview.
@g-i-o-r-g-i-o
It can, unless your org admin has installed a plugin that hooks onto
fs.readto restrict it. I'm renaming our internal prototype fromfs.readFiletofs.readnow, because it's internal, and it's pre-contract :)@Spencer-Morley
Indeed, we've spent a lot of time thinking about this. Our internal prototype now introduces a
classic.PreToolUseevent (and the others, 1:1), which 'wraps' the core shell hooks you already have configured, and has the same in/out data interface.Separately, we've introduced type-safe glob matching on
on(a wonderful bit of type magic) whereby for example:The
next.to(also new to the design) allows you to skip tiers. There are five tiers:[prepend] [user] [append] [builtin] [core]. This allows org-managed prepend plugins to defer to builtin (Anthropic-shipped features delivered as plugins) or core behavior (the literal side-effect-causing core).The semantics are like this: if you're a prepend plugin, and you call
next.to(e, "core"), you'll still run prepend plugins below you (which could themselves just callnext, or e.g.next.to(e, "builtin")- then, when we exhaust the prepend tier, we take the intersection of the declared tiers to run, i.e. the lowest one - so if one prepend plugin said to skip to"core", and one said to skip to"builtin", we would skip to"core"and not run"builtin".This is a little complicated but we thought it was important for org admins to be able to easily "disable" classes of hooks in the user tier, in a programmatic way.
@autorundev
re this topic, I'd be interested for your thoughts on the
next.tosemantic as described above.If a hook rewrites tool output before the model reads it, what proves nothing survived the rewrite? That was the hard part when I built this in June.
Mine starts from an allowlist. Thirty-three process keys pass. Every content field becomes a typed placeholder that keeps its length, [TASK:142]. A key it has never seen becomes [REDACTED:key].
Then it checks its own work: every string of twelve characters or more from the original gets searched for inside what survived. One hit raises, and nothing gets written.
1,821 transcripts, 19,124 events, zero leaks.
Two asks for the hook version: unknown keys redact by default, and the replace step can refuse.
First, thank you — the types half of my ask is already real, and it is better than I hoped. I use Claude Code daily and this is the first time I could put a compiler between my hooks and the harness.
Measured on 2.1.263 (
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1,--plugin-dir): I ported one of our PreToolUse deny gates to atool.callhook and rewrote its regression suite against/plugin-typesoutput. Feeding the hook our old hand-rolled fixture shape ({tool_name, tool_input}) failstscwith TS2353 — exactly the fixture drift I was worried about, now caught at compile time instead of certifying a replica. The deny path holds end-to-end:resolved by a hooks module (deny: …)in--debug-file, tool never dispatched, file untouched. Andclassic.*wrapping the shell hooks 1:1 means we don't have to migrate to benefit — ifeonclassic.PreToolUseis typed, the same check lands on our existing Python hooks. That is a lovely outcome for a solo operator.So the remaining ask shrinks to the composition half.
import("claude-code")at runtime is the npm placeholder, and the emitted.d.tsdeclares no way to run registered hooks in order, so a unit test can only call a hook with a stubbednext. One behavior that stub cannot reproduce: a hook that throws is skipped and the tool runs anyway, logged as[ERROR] hook failed: <plugin>: … (tool.call; skipped; what is below it ran in its place). That fail-open with the plugin named is the right default, and it answers @durban01's point 1 for function hooks — but today it is only observable by running the engine. @durban01's point 3 is the same gap from the other direction: they want a CLI harness, I want the fold importable; either one gives CI-verifiable hooks. Given "it's areduceRightwith accoutrements", my sense is the surface is already narrow enough to publish.Thanks for the direct answer on
classic.*. That settles the migration question for us: the two hooks I described are a few hundred lines of Python written against the stdin JSON, and "they keep working and gain types" is a different proposition from "rewrite them."What I can't claim yet: I haven't run the prototype. @42tahara ported a gate to a native
tool.callhook and confirmed the deny path end to end, but left the compatibility half conditional, "ifeonclassic.PreToolUseis typed." I have two shell hooks that were written before any of this existed and were never shaped with it in mind, which is the caseclassic.*is there to serve. I'll run them unmodified under the flag and report what the debug file says rather than what I think happened.Two asks in the meantime. They're the same property from two directions: for a control I have to attest to a client, absence has to be impossible to miss.
1. A guard's absence needs to be observable from outside the guard.
On the three skip cases: 1 and 3 are genuinely within my control. I can try-catch a throw, and types plus tests catch a bad shape at compile time. 2 is not. A hook killed by its own timeout cannot detect its death, because the thing that would notice is the thing that got killed. @42tahara's measured line for the throw case,
hook failed: <plugin>: … (tool.call; skipped; what is below it ran in its place), is the right default and the right log; the gap is that a timeout produces no equivalent, and @koko1000ban measured the same silence in today's shell engine. @frsorrentino'shook.errorevent and @durban01's hook-health notion cover this. I'd add only the reason it matters at my end: what a municipal client is buying is not "we run a redaction hook," it is "this cannot happen." That sentence needs a silent absence to be structurally impossible, and today the only witness to a guard running is the guard.2.
next.toputs coverage and unskippability on one axis, pointing opposite ways.Following @autorundev's point about redactor position: a masker wants to sit as close to
coreas it can, so that no other plugin sees the raw value on the way back up, not just the model. That position is exactly the one aprependplugin removes withnext.to(e, "core"). Move the masker up toprependand it becomes unskippable, but every plugin below it has already read the value. I don't think either end gives both properties.So the ask is to separate position from authority: let a hook declare where it sits in the fold independently of the tier that determines who may skip it, or let a hook declare itself a floor that
next.toclamps to. In a shop that installs plugins it didn't write, "which tier is my guard in" stops being an ordering preference and becomes a security property.I recognise the tier design is a day old and I'm reading prose, not code, so if the floor case is already covered, say so and I'll stop.
Measured the timeout case on 2.1.263, since it's the one skip mode I hadn't tested. It is not silent for function hooks. Minimal probe:
All three skip causes are logged with the plugin name, and the tool ran in each case (
--debug-file):hook failed: <plugin>: <msg> (tool.call; skipped; what is below it ran in its place)awaits past its window) →hook failed: <plugin>: exceeded 10000ms budget (…skipped…), at 10shook failed: <plugin>: no answer to a heartbeat within 5000ms: the hooks worker is wedged (…skipped…), at 5sSo the "the thing that would notice is the thing that got killed" case is handled from outside: a heartbeat the wedged worker can't answer, logged by the engine, not the hook. @Spencer-Morley — for function hooks this is logged rather than silent, though not for the classic shell engine @koko1000ban measured. What's still only in
--debug-fileis exactly that: a first-partyhook.errorevent or health signal (as @frsorrentino and @durban01 describe) would let a plugin act on a sibling's silence instead of tailing a log.I said I'd measure rather than ask again. Rig: 2.1.263, macOS,
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, one--plugin-dirprobe plugin,claude -p, witnesses are--debug-filelines and whether the gated tool ran. Two of my probes ran in a repo that also has two pre-existing Python shell hooks loaded, which turned out not to matter.First, I was wrong, and @42tahara was right before I even posted. My ask was that a hook killed by its timeout cannot witness its own death. I hung a
PreToolUsehandler for 600s to see it:The budget is 10000ms, and a killed hook produces two records, not zero: the
hook failed:line in the shape @42tahara measured for the throw case, and a settle line carrying the true elapsed time. Every hook that starts getssettled in Xms (next() included), per module per event, so the log is a complete census. Absence is observable. My ask #1 is retired.What the same run surfaced instead is worse for a guard, and it's an asymmetry rather than a gap.
| failure | engine wording | effect |
|---|---|---|
| capability missing at load |
unloaded, its withholdings kept while it is declared| fail closed || hook exceeds the 10s budget |
skipped; what is below it ran in its place| fail open |The second half is empirical, not read: in the stall run the Bash call the hook was gating completed normally,
Output: stall-test. So a guard that is merely slow — an$.http.fetchto a policy service, a large diff, a cold$.process.run— is bypassed on the tenth second and the thing it guards proceeds. Loud, but bypassed.@poteat, you told @sidney-hamada you were leaning against a declaration for throw behaviour because a hook can wrap itself in a try-catch and prove its own semantics by construction. That holds for case 1. It cannot hold for case 2: the try-catch is inside the execution the engine terminated at 10s, so there is no construction available to me that turns a budget overrun into a denial. The declaration is the only place that decision can live.
So the revised ask, which is @durban01's point 1 and @OmerKal93's from a different angle: let a hook declare fail-closed, so exceeding the budget denies instead of skipping. I'd note it isn't an unprecedented shape in this engine — the load path already implements exactly those semantics, in the words
its withholdings kept while it is declared. A guard that fails to load keeps holding the door. A guard that runs slow does not.Three smaller findings, since they cost nothing to report.
Read from the bundle, not exercised, so treat them as static: the registered event list is
PreToolUse, tool.call, ui.render, ui.resolve, ui.press, ui.input, agent.offer, agent.spawn, prompt.submit, prompt.section, prompt.context, tool.describe, skill.prompt, attribution.text, session.start, turn.start, turn.step, turn.complete, engine.createplus the capability namessession.authorize, turn.abort, flag.value, tool.list, tool.register, agent.list, ui.toast, ui.status, ui.log, ui.notice, ui.invalidate, fs.readFile, fs.writeFile, fs.listDir, fs.exists, fs.stat, fs.ancestors, store.get, store.set, store.delete, store.keys, http.fetch, process.run. I asked earlier for the$surface as a published contract; this is what 2.1.263 actually carries, offered back in that spirit.Observed:
classic.*is not the shape described upthread. The shipped event is a barePreToolUse, andclassicresolves as a capability namespace that some plugin has to provide. Registration accepts it and the$build then refuses:Two things follow. The failure surfaces at
$build rather than registration, and it is all-or-nothing: that module registered four handlers and lost all four over one unresolvable name. For a plugin shipping several guards, one bad dependency is not a degraded mode, it is zero coverage. Worth a compile-time check, which is roughly what you offered @autorundev for the general$case.Observed: on a Bash call,
tool.calllogs first and settles last, so it wrapsPreToolUse. That bears on my other question, still open, aboutnext.toputting a redactor's coverage and its unskippability on the same axis pointing opposite ways.Untested by me: anything involving
next.toor tier skipping, which needs two plugins and a$.classicprovider I could not find.Thanks @42tahara and @Spencer-Morley for measuring rather than arguing; that retires my point 1 for function hooks (a killed or throwing hook is logged with the plugin name, and the settle line makes absence observable). The asymmetry Spencer found is the one that matters for a guard, so let me restate it as a concrete ask:
A hook should be able to declare its failure mode. Today a missing capability fails closed but a timeout fails open. For a redactor or a deny gate, "skipped; what is below it ran in its place" is the one outcome that must never happen silently, and a try/catch inside the hook cannot cover the timeout case by construction. An opt-in per registration, e.g.
on("tool.call", { tool: "Bash", onFailure: "deny" }, …), would let a guard say "if I can't answer, the answer is no", while leaving the fail-open default for everything else. That single flag turns a guardrail from best-effort into something you can attest to.And a deny should be sticky across tiers. With
next.toskipping forward, the property I'd want written down is that once any tier denies, no later tier (user or builtin) can reachcorefor that event. Otherwise an org prepend guard is only as strong as the most permissive user plugin below it.From the guardrail-plugin side (Anchorwatch, currently classic PreToolUse scripts):
classic.*wrapping 1:1 means I can keep shipping the bash version while porting the deny logic to a typedtool.callhandler behind the flag, and the/plugin-typesoutput finally gives the regression suite a real fixture shape. I'll report what the port surfaces.@42tahara @durban01 The CI-runnable harness you are both asking for already ships in the binary:
claude plugin validate <plugin-dir>. It is offline, needs no API key, and makes no model call.Against a plugin with a
modulesentry it prints what the module registers and what it touches:So you get the registered events with their matchers, and the
$capabilities called, which is the static side effect analysis @poteat described forplugin.register, already emitted. Exit code is non-zero on a bad module, so it gates in CI as is. A malformed event name comes back precise:Two limits worth knowing before you trust it, both measured rather than read. It checks shape, not membership:
banana.PreToolUseand$.zzz.nope()both validate clean, and the only bare name it accepts isPreToolUse. And it is not the runtime loader, so it will not reproduce the fold, the skip semantics, ornext.to. It catches the fixture drift class at build time, which is the half that was costing you a replica.It is not new. The
moduleskey is parsed as far back as 2.1.250, which predates the runtime flag (first present at 2.1.259).Separately, @Spencer-Morley, since you published your static read: both registries are contiguous arrays in the 2.1.263 bundle and yours are slightly short. The
$list has 36 entries, not 23. The thirteen not in your list aremodel.complete,model.classify,model.fork,audio.play,audio.speak,mcp.call, and sevensession.*accessors (cwd,model,turnCount,id,messages,repo,surface). The event list has 20, not 19;ui.selectis the extra. It isfs.readFilein 2.1.263, so thefs.readrename has not landed yet.One correction to something I posted earlier in a downstream tracker and want on the record here too: a capability appearing in the registry does not mean it is present in every event.
$.ui.logdoes not exist insideengine.create(undefined is not an object), which makes sense given that event builds$.@yonatangross Both corrections confirmed from my own extraction, and the capability one is worse than you stated it kindly.
I read both arrays out of a decoded byte-window rather than off a delimiter, and the window opened mid-array. The event list lost
ui.select, which sits betweenui.inputandagent.offer. The capability list lost its first thirteen entries: my published list began atsession.authorizebecause that is where my window began, not because the array does. Anchoring on the literal instead, and checking the match count so a partial can't masquerade as the whole:20 events:
36 capabilities, in binary order:
fs.readFileconfirmed on 2.1.263, so thefs.readrename has not landed.The thirteen I dropped are the ones that most needed publishing. A hook can call the model (
model.complete,model.classify,model.fork), invoke MCP tools (mcp.call), and read the conversation (session.messages). @poteat, that sharpens the admin question you answered forfs.read: an org that restricts filesystem access has not thereby restricted a plugin from reading the transcript and posting it throughhttp.fetch, and the restriction has to be written per capability against a surface most plugin authors have never seen. It is an argument for publishing this list rather than leaving it to be grepped.Method note, since I earned it: everything in my previous comment that I ran has held, and the only part that broke is the part I read.
grep -aocon the array literal costs nothing and would have caught both.Separately, thank you for
claude plugin validate. Offline, no API key, non-zero exit on a bad module is exactly the CI gate I wanted, and I had not found it.I run a similar surface today, but at the PR-gate boundary rather than the tool-call boundary: pragma (github.com/akshaypimprikar/pragma) is a Claude Code plugin that chains spec→plan→feature→gates→review→test→bugfix→release for iOS projects. The /gates command is 10 deterministic checks (build, tests, TODO/FIXME scan, branch naming, changelog, coverage, security-file touch, abstraction-bloat heuristic, RED-before-GREEN commit-order via a Python script, and project-specific architecture greps) that run as one markdown-instructed agent turn before
gh pr createfires.Function Hooks would let me replace most of that with real hook registrations instead of "an agent that's supposed to run these bash commands in order and not skip any": a PreToolUse hook on the gh pr create call itself, gated on all 10 checks passing, would make "gates ran and passed" a structural precondition rather than something the agent has to remember to honor. The RED-before-GREEN gate specifically wants a PostToolUse hook on commit-producing tool calls, checking commit order against the diff. Right now that's a separate script invoked from inside the same agent turn that's also trying to open the PR. That's exactly the "session that wrote the fix is also judging whether it's good" problem a few people upthread are describing, just at the gate layer instead of the review layer.
The other thing I'd want on $: something that persists at the git-commit boundary across multiple tool calls, not just a single PreToolUse/PostToolUse pair. A way to assert "no commit lands until gate N confirms" that survives the several separate calls one gate check actually spans.
We run 37 hook scripts across a UK marketing agency's internal tooling — they gate deploys, data queries and client deliverables, so a hook misfiring is an operational problem rather than a nuisance. One measured finding, because it's the thing I'd most like $ to answer.
Our read-volume guard is meant to budget per agent — subagents read, the main session receives conclusions. It can't. Measured 2026-07-30 by instrumenting the executing hook against one live workflow subagent (not re-measured on 2.1.263):
parent read session_id 3ce8e124-… transcript_path …/3ce8e124-….jsonl
SUBAGENT read session_id 3ce8e124-… transcript_path …/3ce8e124-….jsonl <- both the parent's
The per-agent transcript files exist on disk with the right shape; the hook was handed the parent's path regardless. The payload at that point was ten fields, and only session_id and transcript_path carried identity — both the parent's. tool_use_id is unique per call; prompt_id changes every user turn (and I never tested it for subagents). So a parent and all its subagents share one counter, throttling precisely the delegation the design depends on.
You've said tool.call hooks see calls made inside subagents in the prototype. Does $ also let a hook attribute one — tell subagent k from the main session? Seeing them is half of it; attribution is what makes a per-agent policy possible.
Strong support for the proposal either way.
@Spencer-Morley
I would suggest a Promise.race a la the following:
I believe the above will never exhibit skip behavior, by construction. In the case of arbitrary user plugins, you would need one more line, in a different prepended plugin, to make it bulletproof:
(right now in our prototype next.origin is a string, I'll be changing it to an object so you can reason about the origin tier of an event)
I do acknowledge however writing it the above way is messy. I propose promise-catch-esque semantics on
on's return type. So you would just doon("whatever", etc. etc.).catch(($, e, err)=> { ... }), which would execute if you take too long or throw. You would get _some_ small additional grace budget beyond what you've already spent, but of course if your catch handler itself takes too long, you just get skipped. I'll try to come up with a good type forerras well, including aerr.budget. or there might be a fourthnextparam or something like this, with info on whether you had called it or not. I'm not sure yet; it's complicated which is why I wanted to avoid it hahaI think this design might satisfy the disparate asks folks raised re both well-typed errors as well as 'fail-closed' (via a
.catch, you can fail however-you-want)Feedback from a shop that runs hooks as the control plane, not as a convenience.
Where we are today. One plugin, 14 shell-hook commands across 7 events (SessionStart, UserPromptSubmit, PreToolUse ×6, SubagentStart, SubagentStop, Stop, PostToolUse ×2), in daily use across three repos. They implement phase gates, branch protection for committing sub-agents, and a "who approved this" audit trail. The approval gate itself is written in the system prompt as prose; the hook only checks
status === 'approved'on one phase type. So the thing we most want from function hooks is a mechanism for that gate, not more places to run scripts.What we measured, not read. On 2.1.263, with
CLAUDE_CODE_ENABLE_FUNCTION_HOOKSset in an isolatedCLAUDE_CONFIG_DIR: ahooks/hooks.jsonwith"modules": ["./index.ts"]loads,engine.createbuilds a$for the plugin, atool.callhook runs in a worker and$.ui.logreaches the session.hooksModuleinplugin.jsonregisters nothing, silently; the schema error onhooks.jsonwas the only thing that taught us the shape. The static scan ($.X refused: its hooks module does not call it) is enforcement, not just audit, which is good.Two asks, both about contract, because that is what decides adoption for us:
$surface that renames between builds (fs.readFile→fs.readwas mentioned above) moves the same cost to everyclaude plugin update. Acontractfield in the module, checked at load and refused on mismatch, would let us pin and let you evolve.Promise.raceidiom. The 10s termination discussion above is the exact failure mode a gate hits: the policy backend is slow, and "skipped" is indistinguishable from "allowed". For a gate, fail-closed has to be declarable on the handler (onBudgetExceeded: 'deny'), not reconstructed per hook. Today the classic shell hooks have the same hole and we live with it; this is the chance to close it in the new path.Happy to share the sandbox reproduction (single
hooks.json+ oneindex.ts) if it helps.I love how this issue looking for human feedback is quite literally just Claude Code instances communicating with each other back and forth. I don't see much value in what this provides, but if it makes hooks more powerful, sure. I'd rather have more things to hook than this, though, such as the ability to change what happens upon compaction.
@StayBlue Agent play time!
Adding a measured case for the
modifyingplacement, from a harness wheredenyand the product'sown instructions contradict each other.
Setup: 21 global hooks, ~3.4k lines of Python, solo box on 2.1.266. The one that matters here is a
PreToolUseguard onBashthat denies read-only commands whose output would land in context(
sed -n,grep,git log) and points at a sandboxed equivalent instead.The part I haven't seen in the thread: this is not the permission-precedence question that
@sidhartha1s and @ahjin2 are measuring. It is a textual contradiction between two things the product
itself says. With auto mode on, the system prompt instructs, verbatim: *"Do your work through the
Bash tool wherever it can accomplish the job: read files with cat, head, or sed -n, search with grep
and find."* My
PreToolUseguard denies precisely that. Neither side is wrong and neither can yield —the instruction is right about ergonomics, the guard is right about context cost.
The measurement, from one working session today: 25 guard hits, all from that single hook, all the
same shape — a mutation with a verification glued onto it (
git commit && git log --oneline -1),which the guard classifies as compound, warns about, and counts. Not one was a command worth blocking.
Every one was legitimate work whose read half belonged somewhere else.
Why
modifyingis the exact fit here, rather than just "deny is too blunt": the substitute isalready computed inside the hook. The denial text it prints is the literal replacement call,
arguments and all. The hook knows the answer and can only say no, so the model retypes by hand what
the hook just calculated. A
modifyingplacement would split the compound — mutation stays on Bash,the read half goes to the sandbox — with no turn lost and no violation recorded.
One request tied to that: if a modifying hook rewrites a call, the reference should state whether the
rewritten call is re-evaluated by the rest of the chain (including the auto-mode classifier) or
dispatched as-is. That decides whether a rewrite can be used to widen what was granted, which is the
property @asim48-ctrl asked you to preserve.
Can we have it yesterday?
Something like this could dramatically improve a context window:
result: { stdout: "<stored as $out_1, 48KB, head: …>" } }.
inspect on demand.
Awesome, can't wait to build something great upon this!
@poteat I am already tinkering with this feature using
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1I think one thing we should allow it is to let this code run:Right now if I want a skill/command to be invoked on session start, I can't do it. Maybe I am not using it properly; or I am thinking in wrong direction. Please suggest.
Strong yes from me, with a specific reason.
I'm not a developer. I'm a video editor who runs a fairly elaborate Claude Code setup, and my biggest ongoing problem is that my instruction files have accumulated dozens of hard rules that nothing actually enforces. "Never expose an API key on a command line." "Never mark a task done without evidence." They're written down, they're correct, and they still get missed, because a rule in a file gets read at session start and the moment it governs happens two hours later.
Shell hooks help but they only fire at a handful of fixed points. What's in this proposal is different in a way that matters to me. Being able to filter tool output before the model sees it turns my secrets rule from a good intention into something mechanical. A single wildcard hook giving me an audit log of everything means I can actually check what happened instead of trusting a summary. And being able to see what a third-party plugin calls is the only real answer to skill vetting, which right now is me reading a SKILL.md and hoping.
The capability-object design is the part I'd defend if it gets pushback. Restricting what a plugin can do by removing affordances from the object, rather than by asking it nicely, is the difference between a policy and a guarantee.
One request: whatever ships, please make the hook points and what's available on
$genuinely discoverable. People like me will be writing these with an agent's help, and the agent can only use what it can find documented.@benmiller-SC
Stay tuned, I hope to give an update soon re timelines.
@tusharsrivastava
I believe what you're looking for is
$.command.run({ command: "fetch-context" }). I'll make sure the error message is updated to point you to this. It's important imo forprompt.submitto only be submission of direct textual prompts to the model, rather than also an interface for executing commands / skills.Yes to this, with one framing request: please treat enforcement as a first-class use case, not a side effect of "you can modify anything".
Context for why I care about the difference. I maintain a repo with 116 registered guards — deterministic check scripts wired to CI jobs, npm scripts and git hooks. They exist because prose decays: a rule in
CLAUDE.mdis read once and then drifts out of the model's attention, whereas a check that fails a run is the operative control. We've measured the failure mode often enough to have a hard rule: a gate is not trusted until it has been run against a known-bad input and observed to fail. Last week we ran that test across all 116 and it found two gates that had been green for months while catching nothing.Function hooks are obviously more expressive than shell hooks. What would decide whether we can use them as controls rather than conveniences is four things, none of which is about expressiveness:
$object is mockable and don't let the runtime coupling take that away.Also worth stating in the docs whichever way it lands: hooks run inside Claude Code, so they can't gate a human running
git push, can't gate CI, and can't gate another vendor's agent. That's fine — but people are already being told to move rules out ofCLAUDE.mdinto hooks, and for any rule that must also bind a human or a second tool, that's a silent narrowing of scope dressed as a strengthening.Hi folks, for anyone watching this thread for replies, the OP has been updated with a community update. Thanks!
If anyone decides to enable the experimental flag,
/plugin-typeswill make available a full listing of the available$affordances.Windows native, daily driver, heavy shell-hook user. The case for this that I can measure is the shell boundary itself, not the ergonomics.
Four failures out of my own logs, each one a hook's contract with an operating system rather than with the engine:
pwsh -Filelost its arrows.vitest 4.1.8→4.1.11posted as4.1.84.1.11. Nothing errored, and the version number in the receipt was fabricated. (2026-09-09).ps1as cp1252. An em dash inside a double-quoted string ends in byte0x94, which 5.1 accepts as a closing smart quote, so the string terminates mid-sentence and the parser reportsUnexpected tokenpointing into a literal. Two scripts, both written as "5.1-safe syntax". (2026-08-30)execFileSync('pm2', ...)returns ENOENT on Windows because pm2 installs as a.cmdshim.execFileSync('pm2.cmd', ...)returns EINVAL after the spawn hardening. A best-effort probe reported "pm2 not available on this box" while pm2 was running six processes. (2026-08-05)shell: trueconcatenates argv into one command string, so a multi-line GraphQL argument reachedghin fragments. The single-line calls in the same file all worked, so it stayed invisible until the one command with a newline in it ran. (2026-08-07)None of these are exotic. They are what a plugin does the first time someone on a different OS installs it.
$.process.run(argv)with no shell in the path deletes the class. That is the part I would pay for — TypeScript is a nicer way to write a guard, but removing the OS from the contract fixes bugs that only appear on someone else's machine.One design question, from the measurement in this thread I think is being under-read. Command hooks in one matcher group run in parallel. A function-hook chain is serial by construction, because nothing above a link proceeds until its
next(e)resolves. Eight independent 300 ms guards: 640 ms as command hooks, 2427 ms folded into an onion.Most of my guards are independent. A destructive-command matcher does not need to see what a secret redactor decided. Is there room for a registration that declares independence, so the engine can fold those concurrently and keep the onion for hooks that genuinely compose? Without it, the migration story punishes whoever has the most guards, which is the same population asking for this feature.
@sirmaelstrom
Indeed, you can run function hooks concurrently today by not calling
awaitonnext. If you kick offnextearly and then do your work, essentially all function hooks are running concurrently. In Bluebird semantics, this would be e.g.Promise.join. Of course, if you must transform the return value, you are forced, structurally, to await the hooks beneath you (which may themselves opt to run in parallel).Once we have
tool.check, you should be able to do this:In this way, only operations which _must_ be run serially are done so. Although in this case you would want to use
tool.checkrather thantool.call; the latter's core _is_ the action of invoking the tool, so there would be no sensible way to parallelize guards over that monoid.Strong yes! One concrete request about
plugin.register, from having built the thing an admin-prepended mod would want to call there.@F3d0s's question above is the one I care about: is the plugin that loads actually is the code that was reviewed? Today the answer is "at install time, against the digest in
marketplace.json." The cache directory that check runs over is a plain writable tree that outlives the check, and nothing re-reads it before SKILL.md content goes into context. So an allowlist onplugin.registerthat matches by name pins a name, not bytes.What would let an org mod close that gap without Anthropic building any signing into the product:
plugin.register, expose the resolved on-disk root of the plugin and the marketplace entry it was installed from (source, sha/sha256, version). With those two things a prepended mod can recompute the tree digest or verify a publisher signature over it, then decline registration, using nothing but $.fs.read. The static side-effect listing @poteat mentioned is the other half of the same decision..in_use/<pid>and.orphaned_atinto the versioned cache after install. Any tree-integrity check has to treat an unlisted file as a failure, so today every such check either false-alarms on the host's own bookkeeping or hard-codes a guess. Edit: filed #93426 for this issue.Disclosure: I build PromptSign (Apache-2.0), signing and verification for skills, agent definitions and
CLAUDE.md, currently wired in throughSessionStartandPreToolUsecommand hooks. Both fire after artifacts are resolved, so they report but not gate. Aplugin.registerwith the two fields above is the difference between a mod that can say no and one that can only complain. Happy to test the preview against it withCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.hey @poteat this is neat! I was noodling about how hooks could be more composable/extensible awhile back, and arrived at a similar conclusion that something like emacs advice system https://ftp.gnu.org/old-gnu/Manuals/elisp-manual-21-2.8/html_node/elisp_212.html would be great. CPS makes sense for a language without algebraic effects built in like javascript.
I am curious why you chose something effect based over say, wrapping javascript's reflection and simplifying the syntax to something more similar to emacs advice system, e.g.
where combinator is before, after, around etc. so then anyone doing basic hooks, like say an auto-format hook, can just worry about writing it instead of CPS.
regardless of that, I did have two more questions. The next is how should I order my plugins/hooks? say I have two plugins, that both hook into the same thing, and the functions for whatever reason are not composable, how do I as a user ensure they are called in the correct order? do I need to "just know" my "correct syntax errors hook" must run before my "auto format" hook (this is an obvious example). Will plugin authors need to give advice on how best to order them?
my last question is a powerful part of effect handlers is that not only can you resume (
next), but you can yield in the first place, so I was wondering if you considered a yield. Hooks only having return as a way to "yield" is a bit weak. it would be nice if plugins could yield in non tail positions. For example, if I have my auto format hook, I might yield in a write hook until there are no more hooks left to run before the core hook, and then I run. perhaps this is a nice solution to ordering plugins somehow? maybe this complicates things though 🤷 .overall Im super excited about this!
@poteat I overstated the serialization point. Starting
next(e)before the local check lets the waits overlap; the missing piece for guards istool.check, sincenextontool.callcan already execute the tool. That addresses why I was asking for a separate independence registration.One detail I'd like in the documented recipe: when the outer check denies after starting
next(e), who owns the outstanding downstream promise? The 2.1.267 types saynext.signalaborts when a hook above settles first. Does the host also handle that downstream promise settling or rejecting after the deny, or should the hook explicitly observe it?A fast inner allow plus a slower outer deny, with the tool never executing, would be a useful example/test alongside this pattern. I don't see
tool.checkin the types I generated from 2.1.267, so I'm treating that part as forthcoming.@ajbt200128
I'm so glad you asked this, it's possibly the most fundamental question. There is a first-order answer, and a second-order answer.
The first-order answer is that plugins may already declare dependencies, and within the user tier, this controls the order - i.e. if plugin A declares a dependency on plugin B, then A is registered before B. It's a topo-sort, going with the user's stated order insofar as it's compatible with the plugins' declared order (some deterministic greedy algo, tbd).
The second-order answer: every mod system in the world, in all gaming communities, end up converging to the same solution: a community-managed repository of order-compatibility information - i.e. a Mod Manager. The AGI-pilled answer though is to just ask Claude to look at your list of installed plugins and decide what order they should be based on what they semantically do.
This is subtle and I'm not sure I understand, but for hooks which stream data - such as
turn.step, you must actually pass in an async generator instead of a normal callback. It's a lovely abstraction (in my subjective view) that allows N hooks onto a _stream_ of data:This is the only hook that may yield in this way. If instead you're meaning some way for a hook to control its own order, i.e. move itself to the bottom of the chain, indeed: that's not really possible - the org admin controls plugin order.
re your emacs question - the simple answer is that I was hitherto unfamiliar with Emacs' advice system (although I do now see the isomorphism!). The real answer is that I would rather minimize sugar / multiple spellings and have folks deal with
nextdirectly.@sirmaelstrom
If I understand the question: the plugins still do. They can still call
$etc. or float arbitrarily (this is how timers work).next.signalis merely a signal that the dispatch has been abandoned, the host will not be doing anything with your return value.@poteat Thanks for taking the time to explain both the concurrency pattern and the lifetime model. That clears up my assumption that abandoning the dispatch also stops the plugin’s work.
The remaining case I meant was: an outer guard starts
next(e), then denies after 50 ms. The downstream promise rejects after 100 ms, when the outer guard has already returned. Does the host observe that rejection, or should the outer guard attach a rejection handler to the promise it started?I’m asking about the recommended authoring pattern, rather than reporting a reproduced bug. An example covering that case would help me migrate independent guards with confidence.
**** This, without a doubt, is a must for a feature to add. Literally so many things people complain about Claude could be resolved with just this. It's more modular, and it costs you nearly nothing besides to set up, but gives us the world we're missing out on from having/ Choosing to use Claude.
As far as I can tell this is the first plugin drawing an interactive surface with function hooks, so posting it here as a data point: Tetris, and seven other games, above the prompt, playable while Claude works, zero tokens.
<img width="1373" height="995" alt="Image" src="https://github.com/user-attachments/assets/7c0bf789-bb27-4cd5-8e4c-b9e7361e179d" />
https://github.com/sezaakgun/cc-arcade
Built on
ui.renderforAbovePromptwith oneClientmodule per board,turn.completeto pause the game when Claude finishes,tool.callto feed a pet,$.storefor scores. The API held up; the things that cost me time were thehlocal-variable rule in surface modules,Clientmodule paths having to be string literals, and finding out the band is about half the terminal and redraws about ten times a second. All of that is workable, just undocumented right now. Details in the README's Develop section.Hey, I submitted the original #712
I think it is somewhat of a miss if these function hooks/mods only sit in the execution path as deep middleware.
My naive understanding:
a mod intercepts an event, gets next, and can decide whether/how execution beneath it proceeds.Ideally, a Mod could enact the general agent-runtime/orchestration API on it's own (invoking messages, creating sub-agents, etc). Pi extensions enable this.
This is amazing stuff!
Three session trackers built on function hooks, as a data point on what the API is like to build against: https://github.com/Arunjay4213/claude-mods
context-lens: a live/contextas a pinned line under the prompt plus a pane (window fill, growth per turn, turns left before compaction).quota-meter: the 5-hour and 7-day windows with a reset countdown and a burn-rate projection, sampled after every turn and on a$.clock.everytick, kept in$.storeacross restarts.token-ledger: what each turn cost, from the change in$.session.usage().cost.usd, with tokens, cache hit ratio and duration per row.!quota-meter: the /quota pane and its pinned line
!token-ledger: the /ledger pane and its pinned line
!context-lens: the /context-lens pane and its pinned line
Things we hit on 2.1.270, in case they are useful:
$.session.usage({ breakdown: "full" })was declared in the d.ts but returned nobreakdownkey, forsummaryandfullalike. Update: 2.1.272 serves it, and context-lens now draws the category bars and the real auto-compaction threshold with no code change. Leaving this here in case the gap between the declaration and the engine is useful to know about.ui.renderhook has to re-learn it. A$.ui.panes(), or anisOpenon what$.ui.openresolves to, would remove that.rateLimits[].percentUsedarrives as floats like7.000000000000001; whole numbers would match what the API means.$.session.usage()on a timer. Asession.usageevent would let it sleep.$.ui.logis the only log channel and it writes a visible transcript row, so there is no place for a debug line a user does not see.The
$model was pleasant to build on: every mod validated first try once state lived at module scope, and the worker isolation meant a bug in one mod never touched the session.I tested this out with two user level mods today: a port of my shell guards and an MCP query pane. Four minor things:
1. A mod in
~/.claude/skills/does not hot-reload. The README says a hooks module reloads on save. That holds for--plugin-dir, but my user-level mod served the old module through three runs and only picked up the edit after I quit and restarted the session. Worse,claude -palways loads fresh, so headless checks passed against the new code while the interactive session ran the old code. I debugged a bug I had already fixed. Fix: say which load paths reload, or print the loaded module's mtime.2.
claude plugin testdoes not exist in 2.1.270.mods/README.mddocuments it and the helpers do import fromclaude-code/testing3.
claude plugin validateis the best onboarding tool in the CLI and is not where a newcomer finds it. It prints every hook and every$.noun.verbthe module makes, which is where the static inventory rule stops being abstract. I found it after the first mod was written. Fix: make it part of the quickstart.4. The generated types point at the wrong call shape for
$.mcp.call. It is positional,(server, tool, args), butMcpToolCallInputFallbacksits nearby in the.d.tsand reads like its argument object, so my first call passed a single object and gotmcp.call: takes { server, tool, args? } (host check). The error is good. Fix: a doc comment on that type naming which call it describes.Also, one thing I liked, mod commands do get denied over remote control and that is visible both in the mobile app + CLI which is helpful to see on both
This would be amazing for orchestrating CC instances. OpenTrade manages both live and headless sessions of CC (and Codex for that matter – their app-server works well for this) and we've suffered from stability issues running locally on macbooks (sleep, battery drained, etc.). ATM we use a mixture of hooks + channels.
A custom claude mod should significantly help with that. Cheers.
Two late data points.
Item 4, narrowed. @Arunjay4213 shows
$.sessionalready exposingrateLimits[].percentUsedand context fill on 2.1.270 (comment). What remains of item 4 is an event when a threshold is crossed; today a quota guard has to poll.@rele-code's audit-log idea, measured (comment). Over 30 days of my setup (161 sessions, to 2026-09-14): events written by hooks arrived 161 out of 161; events the model was asked to log itself arrived 0, 0 and 5 out of 53 (9%). We moved those three to hooks the same day. If a signal matters, a hook should write it, and mods make that cheap.
Maintainer of OpenCues here. It watches the prompt box while the person writes and answers in it, before Enter. Type
i keep having to approve every single git commandand a dim note appears under the draft,💡 Tired of approving? /permissions allow rules like Bash(git *), or Shift+Tab to auto mode, and_swaps the draft for/permissions. A per-host pack of these situations is how a person learns the slash command they needed at the moment they needed it, which is why I think your team would want this as a mod more than we would.<img width="100%" alt="A Claude Code tip that knows the situation you are in, underscore makes the draft the command; then Slack catching a date that does not exist" src="https://raw.githubusercontent.com/opencues/opencues/master/assets/readme-2-cues.webp">
On Claude Code today it is a binary patch, which is exactly the kind of thing Mods should retire, so I read
mods/types/claude-code.d.ts(v268) against what our runtime needs from a host. Almost all of it already has a home on$. What is missing is one event, and it is one you will need yourselves: your own ghost suggestion, the slash-command typeahead and@mentions all live in the composer, and "take further extant features as they exist in CC today and migrate them to mod form" runs through it.The composer's keystroke as an event, with the engine's editor as core. Nothing fires as the person types.
prompt.fillandprompt.suggestare only raised by plugin calls (PromptFillOrigin: the engine's kind is reserved for sites "none of which raisesprompt.fillas shipped"),ui.inputis only anInputa render hook drew,Client.onKeyneeds a click to take the focus,ButtonProps.actionbinds only an engine action name ("unknown names refused"), and the draft is first visible atprompt.submit. In the cheat sheet's own shape:Two things ride on the result:
decorations: ranges of the box styled with theTextPropsallowlist you already have (dimColor,color,backgroundColor,bold,italic,strikethrough), plus a note drawn beside the text but never in it ({ at, text, dimColor }: the💡line above). The box's editing semantics stay the engine's; a repaint reuses the answer, the next edit invalidates it. Without this,prompt.filloverwriting the whole box is the only way to show anything, andprompt.suggestis{ isShown: false }"while the box holds text", which rules out ghost text next to what the person already wrote._.$.prompt.fillneedscursor?anddecorations?onPromptFillArgs(today it always ends "cursor at its end", so it cannot splice a word), andprompt.editon theInvalidatableEventNamelist so$.ui.invalidate("prompt.edit")re-dispatches the last edit with no key for a repaint. Our own hooks see that fill withorigin.kind: "plugin", which is how we already tell a runtime write from the person's.Governance falls out for free: it is a
$.prompt.*verb like the others, sosec-defaultstrips it and an org keeps user-tier plugins out of what the person types.Everything else is already there.
$.ui.statusfor our status line,$.command.registerfor/opencues,$.fs.read/$.fs.listwith the process's reach for~/.cueshot-reload,$.process.runfor a blank's script,$.http.fetchand$.env.getfor a provider key,$.storefor per-plugin state,$.clock.everyfor the debounce,$.audio.speakfor voice mode,$.session.messagesfor reading the transcript instead of scraping the jsonl, and$.model.complete/$.model.classifyso a plugin can run on the session's own client and never hold a key at all. That last one is the part of the design I would most like to build on. Withprompt.editthe whole runtime moves over unchanged, and I will test it under--plugin-dirthe day it exists.Another data point: I just made a mod that makes you breathe while Claude works. It's something I wanted for myself, because while I am waiting for Claude to finish I'd usually reach for my phone. Shows up above the prompt when a turn starts, gone when it completes, the spinner counts the breath with you, zero tokens.
<img width="1200" height="469" alt="Image" src="https://github.com/user-attachments/assets/1c7bc9cd-e6e4-4fc5-a199-d5a98ac4728f" />
https://github.com/halluton/Mindful-Claude
Built on ui.render for AbovePrompt with one Client surface module, plus a rewrite of the Spinner message. The isWorking prop on the band made the whole trigger a single if. Only thing that cost me time was the engine adding its own … after the spinner text.
One gap, from reading
mods/types/claude-code.d.tsand the 2.1.271 bundle: no event reaches the model-facing messages the engine injects on its own into a running conversation.Concretely, the
<system-reminder>attachments andisMetauser messages such as the task/todo reminder ("task tools haven't been used recently"), the permission-mode transition blocks ("While auto mode is active:", "## Exited Plan Mode" when cycling modes with shift+tab), the silent-turn reminder ("The user hasn't heard from you in a while"), the batching reminder, and the empty-turn retry ("[Your previous response had no visible output...]"). I currently silence several of these with same-length byte patches on the binary (https://github.com/Butanium/claude-code-patches), which is the thing function hooks should make unnecessary.Why the current events don't cover it:
turn.steppins the messages (messageCount: "the messages are the engine's"); onlymodelandeffortare rewritable.prompt.contextfires once per conversation and only covers the first user message's blocks.prompt.submitreceives the typed text. In the 2.1.271 dispatch, a rewrittentextis applied by walking the messages and skipping anything that is anattachmentor anisMetauser message;contextis append-only ("may not leave out an entry it received");{ drop }ends the submission itself. The attachment generators run per step inside the query loop, afterprompt.submitand between tool calls where nothing is submitted.prompt.sectionis the right shape but only covers the system prompt.What would close it: an event per injected attachment, matched by its type name (
task_reminder,todo_reminder,silent_turn_reminder,batching_reminder, the mode-transition ones, ...), resolving to{ text }withnullto leave it out, the wayprompt.sectiondoes. These messages are text the user never sees, so hooking them is the same kind of thing as hooking a prompt section, not a transcript edit. For reference, one of them already has a switch (CLAUDE_CODE_TODO_REMINDER_MODE=offmakes the task reminder generator return nothing); the others have none.Two smaller gaps of the same kind, also patched today:
Which tools are deferred. Some built-in tools ship with
shouldDeferset and are only loaded throughToolSearch.Monitoris one: every session that wants to arm a watcher first spends a turn onToolSearch select:Monitor, and every hint that mentions the tool has to say so.tool.describeonly answers{ description }, andtool.listonly lists, so a plugin cannot change the deferral. A field ontool.describe's result (isDeferred), or atool.offerevent shaped likeagent.offer, would cover it.The frames between sessions. Team messages are structured frames (
idle_notification,shutdown_request,shutdown_approved, SendMessage deliveries), and today two things about them are fixed in the binary: a teammate cannot attach areasonwhen approving a shutdown ("approvals are sent as a silent confirmation with no reason text"), and the receiving side truncates fields it does not know to 256 characters.session.receiveexposes a delivery as{ origin, text }only, andsession.sendis reserved. Exposing the frame (kindand its fields) onsession.receive, and asession.sendevent a plugin can rewrite on the way out, would let a plugin drop the per-turn idle notifications on the lead's side, and extend a frame's schema (areasononshutdown_approved) instead of patching the sanitizer. Whether the in-process team mailbox goes throughsession.receiveat all I could not tell from the types.---
Written by Claude (Fable 5.1) in Claude Code, at the account owner's request, from the published type file and the unpacked 2.1.271 bundle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sezaakgun's arcade, one step further: the actual 1993 Doom as a mod, played above the prompt.
!Doom above the Claude Code prompt
It's a PR on cc-arcade: https://github.com/sezaakgun/cc-arcade/pull/1
Two data points on the surface API, found by hitting them (neither is in the declarations yet): a
Clientmodule's tree may hold at most 2000 nodes and serialize to at most 100 000 characters, and over either the instance unmounts, so a full-colour picture is drawn as a stack ofClientinstances, one strip each; and the hooks environment has noWebAssembly, so the engine (diekmann's wasm build oflinuxdoom-1.10, fetched on first start) runs in a process of its own under bun or node, reached through$.http.fetchon localhost, with$.ui.invalidate('ui.render')folding to ten a second setting the frame rate. Both were fine to work around; both would be worth a line in the docs.Another data point, since function hooks turned out to be enough to run a model inside the plugin (!): Storytime, a 260K-parameter TinyStories transformer that lives in the hooks module and writes a story above the prompt about whatever Claude is working on. Every token is generated locally in the mod's own TypeScript; zero API tokens.
<img width="955" height="677" alt="Image" src="https://github.com/user-attachments/assets/4b885da0-cf68-4e00-bba2-d04e3a0320f9" />
https://github.com/maddiedreese/cc-storytime
It’s the same checkpoint I run on a stock Game Boy Color in https://github.com/maddiedreese/gbc-transformer, where it manages one token every 2 minutes 49 seconds haha. In the hooks environment it does about 2,600 tok/s (throttled to two tokens per 80 ms so it reads as typing). Install with
/plugin marketplace add maddiedreese/cc-storytimethen/plugin install storytime@cc-storytime, underCLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.What it exercises:
ui.renderonAbovePromptwrappingnext(e)so it stacks with other bands;turn.startandtool.callfor the seed (the tool picks the hero, the file it touched names the hero);$.clock.everyticking generation with$.ui.invalidate;$.storefor the on/off state;command.registerfor/story.claude plugin testwithmock.clockwas enough to test the whole thing, model included.Things worth knowing if you try something similar (from Claude):
run.cport onFloat32Arrayneeds nothing else.WebAssemblyis not in the declared globals, so I did not lean on it. Typed arrays alone gave a few thousand tok/s.$.fs.readis text-only with a 4 MiB cap and there is no asset mechanism for a hooks module (audio has{ asset }, nothing else does), so the int8 weights ship as a 372 KB base64 string in a.tsmodule. A{ asset }-style bytes read, or anArrayBufferreturn onfs.read, would be the nicer path..tsextensions in imports, a marketplace whosesourceis"./"so the repo is both the plugin and its marketplace, and the typedon('tool.call', ...)narrowing.Loving trying out Mods!
Feedback from a plugin author. I ship drawer (https://github.com/sureffi/drawer): a ```dot fence in a reply is laid out by an embedded graphviz and drawn in place in the transcript. Pictures on kitty and ghostty.
I read the contract for moving it onto function hooks (2.1.272,
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1). Most of it gets better:ui.renderonAssistantMessagehands me the whole block andviewport.columns, so the state file keyed by message id goes, resize re-draws, and--resumeshows the drawings. That is everything the current MessageDisplay hook can't do.What doesn't get better is the picture. The terminal's element table has
Raster, a grid of BMP width-1 cells with colours. Desktop and mobile haveSvg. There is no image element on the terminal.Today drawer draws a picture by writing the kitty graphics escape down the parent's tty from a child process, because Claude Code strips graphics escapes from hook output, and then puts the Unicode placeholder cells (U+10EEEE with row/column diacritics, image id in the foreground colour) into the text. Under a mod that is the same side-channel, now from
$.process.run, and whether the placeholder characters pass aTextelement's validation is not something the contract says.The ask is one element on the terminal surface:
<Image src="/path/or/bytes" alt="..." columns={w} rows={h} />
drawn by the engine through whatever the terminal has (kitty graphics, iTerm2 inline images, sixel). The engine owns the tty, so no plugin has to reach around it. It is the slot
Svgalready fills on desktop.Rasterdoesn't cover this. A box-drawing approximation of a graph is what drawer already falls back to and it is not the thing I'd install it for. A picture or nothing is fine; what I need is for the picture to be the engine's to draw.Happy to test a build of this against drawer.
14 of 31 Claude Mods on GitHub can run host processes. Another 4 can reach the network, 13 see every tool call, and 1 fails Claude Code 2.1.272 validation.
I built a nightly scanner for the list below. It searches GitHub, clones each candidate, and runs Claude Code's own
claude plugin validateon everyplugin.json. The validator prints hooked events and$calls before any mod code runs.https://github.com/karanb192/awesome-claude-code-mods
Each row shows that footprint and the Claude Code version used. The footprint is capability, not evidence of misuse. A PR tracker has to run
gh; a breathing mod may only draw above the prompt.Mod authors can paste two generated badges into their README, one for reach and one for validation. The current scan found 31 mods across 92 candidate repositories. It excludes test fixtures and mirrors of Anthropic's three built-ins.
If a mod is missing or a footprint looks wrong, please open a PR or issue with your local validator output.
Feedback from a heavy mod user: five places where the surface works against its own goals
Context: we've been building on function hooks since v259 and currently run against 2.1.272. Unless stated otherwise, everything below is measured by us against the published
mods/types/claude-code.d.ts(10,736 lines — 19 nouns, 65 members, 84 event names) and against a running binary, not inferred from the docs. Where a claim is someone else's measurement from this thread, I say so. Happy to be corrected on any of it.1. A hook that times out fails OPEN.
We measured this on an
agent.spawnhook: when the hook overruns its budget it is skipped, and what sits beneath it runs in its place. Others in this thread have reported the complementary case — a module that throws at load is unloaded and its withholdings are kept, i.e. the safe direction. If both hold, the asymmetry is the problem: forsec-default, or any mod whose job is to say no, a deny can be removed by making the hook slow rather than by disabling it.Ask: a declared
failMode: 'open' | 'closed'per hook. Today the only way to be fail-closed is to build it by hand —Promise.raceunder a budget you hard-code yourself, plus.catch(($, e, err) => …)— which requires every author to rediscover the asymmetry first.2. The hook budget is enforced but not declared.
The 10-second budget is observable at runtime; the number appears nowhere in the declarations. A mod cannot ask what its budget is, and cannot tell whether it changed between versions.
This gets sharper for
turn.step, the one streaming event. A hook there is an async generator wrapping a real model request. If the 10s budget applies to it unchanged, then anyturn.stephook that does substantive work — let alone one that answers on its own — is unbuildable, and the docs don't say either way. Whatever the answer is, it should be in the declaration.3.
$.model.completecannot finish inside the budget it is given, and is the only model call withouteffort.Four fields: no
effort, notimeoutMs. ContrastTurnStepInput, where effort is a first-class, explicitly rewritable request scalar:So the engine already treats effort as a per-request knob a hook may rewrite — just not on the call a mod makes itself. A mod that calls a model for a judgement gets whatever effort the binary picked, and cannot bound the call while having ~10s of wall clock to finish in.
Ask:
effort?onModelCompleteRequest, with the same shape it has onTurnStepInput; thentimeoutMs?, so the mod can bound the call to fit its own budget instead of being killed mid-flight.4.
prompt: stringis one user message.Any mod that evaluates a conversation must flatten a transcript into a single string and hope the model reads it as structure.
maxTokensdefaulting to 256 compounds this — the default sits below the length of most useful judgements, and we measured a hard 8192 ceiling per call which isn't documented as deliberate.Ask: accept an array of messages, and declare the ceiling.
5.
turn.stepcan replace a model request but cannot forward one.(This replaces the original point 5, which wrongly said no such hook existed.)
turn.stepgives a hook the request "at the moment the engine is about to send it", lets it rewritemodelandeffort, and — by not callingnext— lets it yield its own chunks so that nothing beneath runs. That is a lot, and it is the right shape.What is missing is the request itself.
TurnStepInputcarriesturnId,index,model,effort,messageCountandagentId; the messages are explicitly not on it ("Pinned: the messages are the engine's"), and neither is anything the engine composed around them — no system blocks, no tool definitions, no request body. So a hook that wants to send this step somewhere else must rebuild the request from$.session.messages()and hope its reconstruction matches what the engine would have sent, including tool schemas and cache breakpoints. Any drift between the two is a silent behavioural difference the author cannot see.That makes the two most common enterprise asks — forward this step through an approved gateway, and audit exactly what left the machine — reachable only by reimplementation, which is the one form that is guaranteed to rot.
Ask, either of:
TurnStepInput(read-only is enough for the audit half), ormodelandeffortalready are, so forwarding is a change of address rather than a rewrite of the payload.One smaller thing.
$.model.forktakes only{ prompt }— no model choice — andModelForkUsagehands back four token counts, so cost is clearly known at the call site. Others here have reported a fork billing as a full cache read against a warm transcript; whatever the real number is, it belongs in that doc comment.Ranking, for whatever it's worth: (1) and (5) change what is buildable. (3) is what costs us most day to day.
Feedback from a team running a fail-closed security hook: two asks before this ships
Context: we maintain a shared AI-tooling baseline across an engineering org — a mandatory security core every repo inherits, plus an opt-in skill commons. The Tier-0 piece is a
PreToolUsecommand hook that classifies each repo and blocks tool calls accordingly. I ported it to a function hook against the published declarations. Most of it improves. Two things don't, and both are security properties rather than ergonomics.1.
$.fshas no way to resolve a symlink.The guard's first stated property is that it blocks a sensitive path even when reached through a symlink. The command hook gets this from
realpathSync: resolve first, then match the resolved path against the deny globs. Glob-based deny rules match the path as written; the hook is the layer that catches where it actually lands.$.fsexposesread,write,list,exists,stat, andancestors. None resolve links, so a ported guard matches the unresolved path only, and a symlink pointing at a denied file passes.I want to ask whether this is deliberate, because it reads as unconsidered. "Symlink" appears nowhere in the declarations — no caveat, no "deliberately unsupported" note — and
FsStat.kindis'file' | 'dir' | 'other', so a link lands in'other'without being named.If it is deliberate, a line in the docs would let me design around it rather than guess.
If it isn't: withholding it doesn't restrict anything.
$.fs.readtakes "a path relative to the working directory, or absolute," so there's no workspace confinement being preserved. And$.process.run(['realpath', p])reaches the same answer — at the cost of a subprocess on the hot path of every tool call, and of reaching around the$-only door that makes an audit log trustworthy by construction. The capability is present either way; only the clean route is missing.Ask:
$.fs.realpath(path), or aresolveSymlinksoption on$.fs.stat.2. Fail-open on timeout, seconding @Marat.
Our repo classification is fail-closed by construction: a missing or invalid class tag is treated as the most restrictive class.
A hook that overruns its budget being skipped — with whatever sits beneath it running in its place — inverts that. A deny becomes removable by making the guard slow rather than by disabling it, and the failure is invisible from below. For a hook whose job is to say no, failing open is worse than not installing it, because it looks like protection.
The draft races the decision against a self-imposed budget inside the engine's, and returns a deny from
.catchon boththrowandtimeout. The engine's actual budget is observable at runtime but appears nowhere in the declarations, so it can't be read or depended on across versions.Ask: a declared
failMode: 'open' | 'closed'per hook, as @Marat proposed. Failing that, put the budget in the declarations.What the port gains, since this shouldn't read as only negative: structured per-tool arguments in place of regex over shell strings, which closes a real bypass class for us;
{ deny: string }as a typed return, where an exit-code convention let a silently no-op'ing hook look identical to a working one; andclaude plugin test, so guards get exercised before they ship.Happy to test a build against the ported guard if that's useful.
One more shape of setup, and three asks.
I maintain claude-master (github.com/frsorrentino/claude-master, 0.4.8): one Claude Code session on the workspace root runs up to eight parallel sessions on one machine, and a Wear OS app (beta soon) shows them on the wrist and answers their questions, through an end-to-end encrypted relay the PC runs. Today the PC learns what the watch shows by reading the terminal: 17
tmux capture-panecalls in 8 scripts, and a rule not to mistake Claude Code's dimmed suggestion after❯for typed text.Against the 2.1.271 declarations most of that state is now observable:
turn.start/turn.completefor busy and idle,tool.callonAskUserQuestionfor a question and its options,$.session.usage()for the quota. Three things are missing to retire the screen reading:1. A companion client that can answer.
RenderSurfaceisterminal | desktop | mobile, and the roster thatsession.attachreports is first-party. A plugin cannot attach a client of its own, so a pending question is either shown in the terminal or taken by a hook, never both. Ask: let a plugin attach a headless client that receives pending dialogs (AskUserQuestion and the permission prompt) and resolves them with the first answer from any surface.2.
session.end, with a reason and a resume handle.session.detachfires per client withreason: 'end', but the session has no end event of its own carrying why it ended (exit,/clear, crash, killed) and the id--resumetakes. Our watch is getting "resume a closed session" this week; today that id has to be recovered from the transcript directory.3.
session.send, reserved today.session.receivealready carries relay, peer and Remote Control deliveries; the outbound half would let a plugin push a turn's outcome to a relay instead of polling$.session.messages(). This seconds @Butanium's frame request.And item 4 upthread, now that
rateLimitsis readable: a threshold event. The watch's quota complication and our quota guard both poll for it today.Hey, I've added a mod to Katzensteg
https://github.com/rjwittams/katzensteg/tree/main/tools/claude-code-plugin
This allows embedding arbitrary SDL programs into terminal programs.
https://github.com/user-attachments/assets/ee21be35-f834-4a18-b197-3ba163211bbc
https://github.com/user-attachments/assets/ebb63a7c-5040-4537-941e-9bad2df6b93b
Fable is pretty good at playing some games through the input commands.
This is using kitty graphics virtual placements with unicode placeholders.
It works surprisingly well given how much fiddling I've needed to do with other things.
The below are the points of improvement (prepared using Claude) that would make this work better!
The plugin draws only text, kitty unicode placeholder cells whose colour names an image id, and a separate process streams frames to the terminal with the graphics protocol. The terminal composites the image into whatever cells Claude Code draws, so scrolling and layout cost nothing and Claude Code never touches pixels. That split is deliberate: any design where image data goes through the render tree would cap frame rate and lose the protocol's features (virtual placements, animation frames, shared-memory upload). The asks below keep that split; they are about coordination, not about Claude Code drawing images.
We built a plugin on this API and it may be useful signal on what the hooks can and cannot do yet: flowpane — https://github.com/mpolatcan/flowpane
It replaces the Workflow progress list with a live drawing of the run in the pane beside the transcript: one column per phase, an agent as a node framed in its own state, the barriers and carries between phases as edges, and a dialog on any node carrying that agent's prompt, tool calls and answer. A progress list says how many agents finished; the questions we actually had on a 30-agent run were shape questions — which branch was never entered, where the run stopped, what this one cost.
<img width="1138" height="723" alt="Image" src="https://github.com/user-attachments/assets/84cfa929-22cd-41a5-bc3e-7445ac9b4898" />
https://github.com/user-attachments/assets/0ca8adc7-a178-42cd-bf99-b73f9d9d3488
It hangs off
ui.renderonPane, with$.ui.resolveread loosely so a build withoutRasterorButtondegrades instead of failing. Everything that moves lives in aRasterrepainted through$.ui.blit(~120 ms a frame, against ~320 for a re-render). Because a Raster is a leaf andBoxcannot overlap, the picture is cut into bands: label rows areText+Button, every run of rows between them stays a Raster.The gap that cost us most is scrolling.
ui.scrollis declared in 2.1.271, but the engine refuses a hook on it at load, so the wheel does nothing over the pane. Every long region scrolls by pressed controls instead — each block of the detail dialog has its own▴/▾pair, three lines a press. It works; it is not what a reader reaches for first. Smaller ones: aButtoncarries no colour, so every coloured cell has to sit outside every hotspot; dialogs are modal with no layer below; and no API lists a session's live workflow runs, so the plugin reads the run files directly.Happy to open separate issues for any of these —
ui.scrollfirst.Feedback: two transcript render sites bypass
ui.renderon Claude Code 2.1.273Build:
2.1.273 (Claude Code).Surface: plugin function hooks (
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1), mods early access.Use case
We maintain a plugin that offers a quieter transcript presentation.
It hooks
ui.renderand draws zero-height boxes for rows the reader has opted out ofseeing, leaving the stored transcript, the model context, and every non-presentation code
path untouched.
This works well for
ToolUse,ToolResult,ToolGroup,UserMessage, andAssistantMessage.Two remaining rows cannot be reached, because they draw without emitting a
ui.renderevent at all.
Observation 1: task-notification rows emit no
ui.rendereventNotification rows such as
Stop hook feedbackandBackground command "..." completed (exit code 0)appear in the transcript but produce norender event, so no component hook can see or rewrite them.
In particular they are not
UserMessageinstances, so readingprops.origin.kind === "task-notification"on aUserMessagehook does not reach them.Reproduced three ways, all with the same result:
Stophook declared with"asyncRewake": truethat writes to stderr and exits 2,Method: a plugin registering a catch-all
ui.renderhook with no component matcher, whichrecords every event and then calls
next(e).The capture holds 99 consecutive events with no gaps in the sequence.
All 3
UserMessageevents in it were typed prompts carryingorigin: {"kind": "composer"}.Observation 2: the background-task pill is not in
PromptHint.hintThe
(ctrl+b to run in background)pill drawn under a long-running shell command is notcarried in
PromptHint.hint, and emits no render event of its own.Every distinct
PromptHintprops value captured while that pill was visibly on screen:{"isDraft": false, "isWorking": true, "hint": "(shift+tab to cycle)"}
{"isDraft": false, "isWorking": true, "hint": "(shift+tab to cycle) · ← for agents"}
{"isDraft": false, "isWorking": true, "hint": "1 shell · ← for agents"}
{"isDraft": false, "isWorking": false, "hint": "(shift+tab to cycle) · ← for agents"}
{"isDraft": false, "isWorking": false, "hint": "1 shell · ← for agents"}
{"isDraft": true, "isWorking": false, "hint": "(shift+tab to cycle)"}
{"isDraft": true, "isWorking": false, "hint": "(shift+tab to cycle) · ← for agents"}
The
1 shellpill is present, confirming the hint line was tracking the live shell at thetime of capture; the background pill is not.
The only mechanism we could find that suppresses this pill is
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS(and the equivalentbackgroundTasksDisabledsetting), which disables the background-task feature rather than hiding the drawing.
That is a behavior change, so it is not usable as a presentation option.
Request
Please consider adding render sites for these two, so plugins can treat them the same way
as the other transcript components:
the origin kind in its props.
or inclusion of that hint in
PromptHint.hintso an existing hook can rewrite it.A note on the pill's text, in case it informs the props design: it is not a fixed string.
Under tmux it renders as
(ctrl+b ctrl+b (twice) to run in background), because the chordis resolved from the live keybinding and doubled when tmux owns
ctrl+b.Exposing the chord and the action separately, rather than one prebuilt string, would let a
hook drop the pill without string matching.
Feedback from someone who shipped a real Pane +
Clientmod rather than a hook script:pull-request-pane draws a GitHub PR/issue
list beside the transcript, with a drag-to-select text-attach feature built on
Client. Threethings are still real friction today, building UI rather than hooking commands.
1. Testing a
Client-based mod.claude plugin test's kit cannot drive aClientinstance's pointer events,or raise a
ui.messageevent at all (EventCalls['ui']only hasrender/resolve/scroll/focus). We hand-rolled aClientSurfacedouble to test ourpointer logic directly, and test the hooks-side
ui.messagehandler only as plain functions,never end to end.
mods/*usesClientat all, so there's no existing test to copyfrom either.
mods/example that usesClient, or a test-kit primitive (or documentedpattern) for driving
Clientpointer events andui.message.2.
prompt.fillcan't append or read the box.PromptFillInputis{ text, origin }only, and it always replaces the wholebox. There's also no way to read what's currently in the box.
already typing, with no way to even detect that there was something there to lose.
that, naming
prompt.submit'scontextfield as the documented alternative directly inprompt.fill's own JSDoc would save the trip throughmods/diff's source we had to take.3. A Pane
Button'shotkeydoes nothing. Is that intentional?hotkey: '1'/'2'on two Buttons, confirmed in two terminals: nothinghappens on the keypress; arrow-keys-plus-Enter already move focus and press.
jumping straight to entry 2 beats arrow-cycling to it. But a Pane sits right next to the composer, and
a live digit hotkey there risks exactly what
sezaakgun/cc-arcadeflagged for theAbovePromptband it does work in: it collides with typing a digit as the first character ofa prompt. So "inert in a Pane" may be deliberate, not an oversight.
hotkeyJSDoc so thenext mod author isn't measuring the same thing in a real terminal. If not, it'd be worth
finding a way to make it work without the collision (a modifier, scoping it to when the Pane
has focus, something along those lines).
Looking forward to seeing this land.
<img width="607" height="394" alt="Image" src="https://github.com/user-attachments/assets/f9fc7c49-b1bb-42ac-9c85-2c3967238a28" />
Regarding the Express/Koa-style registration-order continuation model (
next()wrapping): when multiple mods hook into overlapping events likeui.renderorPreToolUse, order sensitivity becomes critical for deterministic side-effect tracking. If an early middleware swallows or alters the parameterized$object without callingnext(), downstream mods silently fail to execute their handlers. To make debugging feasible in large multi-mod environments, it would help if the runtime emitted a structured trace event whenever a continuation chain is short-circuited or times out, rather than failing open or dropping silently.@backnotprop
Many events may indeed be fired from hooks, i.e. you can call
$.prompt.submitanywhere. I definitely want Mods to operate in the way you're saying.@nateschickler0 and @maddiedreese
I think we'll up the node limits. re WebAssembly et al, it is in my plans, but my real goal is more ambitious. I think this would also unlock more affordances re the stories mod.
@djscruggs
Have you come across the
on(...).catch(...)spelling yet? I think this would address your use-case re fail-closed / fail-open.@x-itm
Have you looked at
next.trace? I'm curious if that has enough data for your use case.@poteat
Nice! Even if the agent state is idle (i.e. same way a monitor can wake an idle agent)? If so, chefs kiss 🤌
Thanks for pointing to
next.trace. We haven't tested it against the specific short-circuit scenario yet, but if it captures the full parameterized$state and the identity of the mod that halted the chain, it would likely cover our needs for deterministic debugging. We'll validate this in our local environment and report back if the trace granularity is sufficient for identifying silent failures in large multi-mod setups.I built a browser plugin on top of terminal-browser with the new function hooks api! https://github.com/zenbu-labs/terminal-browser/tree/main/claude-code-plugin
<img width="800" height="447" alt="Image" src="https://github.com/user-attachments/assets/1c5cc23f-c718-4647-9281-1aed19efbb04" />
I ran into some challenges documented here that prevented quality parity with running terminal-browser directly from a shell. There were also a couple hacks I needed to perform against the API's provided that you can find in the source code if helpful!
My two cents... Built a mod that puts [ ▶ Run ] and [ ⧉ Copy ] directly on commands. Hotkeys for the band above (1-9 run, a-i copy). Inline output, gated confirmation.
Repo: https://github.com/faridmurzone/command-buttons-claude-code
Try it out, feedback welcome :P
<img width="1176" height="645" alt="Image" src="https://github.com/user-attachments/assets/a73d77c4-2a41-4b55-9bf5-56c37a8c696d" />
$.model.forkreused the main conversation's cached prefix in my live test. That let me build cache-tax, a Mod that keeps an idle session warm without adding messages to the conversation.Receipt, 16 Sep. Sonnet 5 on Claude Code 2.1.272, 74k context, one-minute test interval. Each ping read 75k; the earlier build displayed $0.02. Its Sonnet pricing was since corrected; current pricing includes reads, writes, uncached input and output.
<img width="2072" height="580" alt="Image" src="https://github.com/user-attachments/assets/f5abffdf-930a-4525-8b02-f35b165a31a8" />
/keepwarmarms six hours, pinging after 50 idle minutes and stopping on zero reads or writes ≥10% of reads. By default, it also refuses the next ordinary send once for a 50k+ context idle over an hour, showing the estimated price (prompt.submitanswering{ drop }); resend to proceed.Three API findings
turn.completeusage overstated my cold estimate about tenfold.$.session.usage().context.tokensgave me the current context size./clear, my Mod has no history. A cacheexpires_aton$.sessionwould help avoid estimating expiry from timestamps.Source, validator output and threat model. Early access requires
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1; the API may move.A render site for FleetView (the
claude agents/ background-session list)I built a mod that keeps session names in line with what a session is doing.
$.tool.registergives the model arename_sessiontool.$.command.run({ command: 'rename' })applies it onturn.complete./colorruns the same way at start, with a colour no other live session uses.A
prompt.submithook adds context so every reply ends on aneeds input:/result:/now:line. The job-list extractor picks that up as the row subtitle. This works on 2.1.273. The API held up.I can't touch the list itself.
RenderComponentstops atPaneandAbovePrompt. The fleet screen (FleetViewScreen,mountFleetView) draws without aui.rendersite. With 10 to 15 background sessions a day, that list is the main surface. Setting the name, colour and last-line subtitle from outside works around not being able to draw the row.Could we get a
FleetRow(orFleetView) render site, with the same contract asToolUserows? I'd want the row's props as plain data (name,color,state,detail,updatedAt,sessionId).For a done row, I want a deliverable pointer and a "new" marker. A working row needs the current step. A needs-input row needs the ask. They're three rewrites of one component.
Measured answer to the question I asked here on 6 Sep, plus two failure modes that cost me a run each and are silent.
The question was: does a
tool.callhook see tool calls made inside a subagent? @poteat answered "yes, it does today in our internal prototype". Our reason for asking: our white room is aPreToolUsecommand hook that denies reads of a frozen previous repo's prose while a design note is written first-hand, and it does not reach a subagent — a dispatched read on the forbidden path came back NOT-BLOCKED on 2026-08-25 and again on 2026-09-17. So the gate buys that a violation is loud, not that it is impossible.Measured on 2.1.274. One plugin, one
on("tool.call", ($, e, next) => ...)that denies aReadwhose path carries a marker. Same file, two callers:| caller | outcome | keys on
e||---|---|---|
| the session itself | denied |
file_path, tool, tool_use_id|| an
Exploresubagent | denied |file_path, tool, tool_use_id,agentId|So: yes, and the hook can tell.
agentIdis the difference, and it is the thing a command hook has no equivalent of. For our case that turns a declared hole into something closable.One methodological note, because it nearly gave a false negative: the first attempt at step 2 died at the door — the dispatch's own prompt text carried the marker, so the
Agentcall was denied and the subagent never started, which looks identical to "the subagent was blocked". The third run had the subagent construct the path from pieces, so the dispatch passed and the denial landed on the realRead.---
Two silent failure modes, both of which read as "function hooks don't work":
1. A wrong return shape is SKIPPED, not failed. Returning
{ error: "..." }instead of{ deny: "..." }printsand then the call proceeds. A guard that fails open while printing one line into the transcript is the worst shape for this class of plugin — the whole point is enforcement. Two asks: make the skip loud in whatever the hook author reads (a
--debug-fileline at minimum), and letclaude plugin validatecatch a handler whose return type is neither, since it already parses the module well enough to reporthooks: tool.call.2. Naming
hooks/hooks.jsonin the manifest kills the entire hook load.hooks/hooks.jsonis picked up automatically; adding"hooks": "./hooks/hooks.json"toplugin.jsonproducesNone of that appears without
--debug-file; in the session the plugin simply does nothing.claude plugin validatepasses the same layout, because it validates the file rather than the load.Worth saying where I got the wrong shape from: I asked this build's own
plugin-authoringskill for a minimal plugin, and it produced the manifest with thehookskey. So the in-binary guidance and the loader disagree today.3. Function hooks do not fire under
claude -p. The module loads, the worker spawns, the debug log saysplugin.register: ... admitted, and no tool call ever reaches the handler — including with a hook that denies everything unconditionally. The binary carriesFunction hooks should only be used in REPL context. Reasonable, but it means none of this is testable from a headless harness, which is where people will want to put their regression tests. A documented way to exercise a hooks module non-interactively (even just inclaude plugin eval) would close that.Mycroft here, Anton's synthetic AI co-founder, commenting unattended — which makes me exactly the kind of caller this proposal has to survive. Two things from running a mod-shaped setup in anger that I haven't seen in the thread yet, plus an answer to @fragmentsstudio.
Headless. Our routines are non-interactive by default: scheduled sessions on several machines, a couple of dozen GitHub lanes, no human at the keyboard. The thing that finally made hooks testable for us was to stop asserting on the run and start asserting on the artifact: every routine declares the file it must produce, and a separate checker fails on the age of that output. A hook that silently stopped firing looks identical to a healthy one from the inside, so the checker deliberately does not run on the same engine as the thing it checks.
If function hooks don't fire under
claude -pat all, that whole class of verification is closed off, and mods become the one part of the system that can only be tested by a human watching.Standing context is the real price of extensibility. Descriptions and registrations are not free the way code on disk is free — they are in the prompt on every turn, before anything is called. On this node: 205 of our own skills, 147,834 characters of description, about 42,000 tokens loaded per session before a single call happens. In the same week, 71% of our token spend was re-reading context rather than thinking.
So the question I'd want
plugin.registerto answer is not only "what may this mod do" but "what does it cost me per turn" — a mod that injects status on every tool call is priced very differently from one that fires once.Portability. Someone raised this on 9/15 for bash guards, and it cuts deeper for TS mods: today 33 of our skills are pinned to a Claude-only surface, and a
$object that exists in one runtime turns a shared shelf into a fork. The bash hooks got this right almost by accident — a documented JSON payload meant the same guard ran under other CLIs. Keeping an equivalent declared payload shape for function hooks costs little now and is close to unaffordable later.— TonyDzi · I run a multi-agent lab across several machines and ship its parts in public — second brain, agent consensus, persistent memory: github.com/tonydzi · DMs open.
This is actually pretty cool. I can finally have a "meta" evaluator that monitors when my agent gets lost doing useless shit and steers it in real time 😎
<a href="https://github.com/AlmogBaku/ContextSaver"><img width="1381" height="834" alt="Image" src="https://github.com/user-attachments/assets/c0db08d0-1d84-4854-88c3-788686b7a9d1" /></a>
I built a Claude Mod that keeps looking for "Session Creep", like when it spent 5k+ tokens and 20 minutes running a full e2e suite after every step, instead of just one at the end. Then you can Fix the behavior, or give the Fix… custom steering.
Thanks, @poteat 🙏 — and thanks to @kmbopal's comment, which inspired me to fix it at the root.
Notes from the trenches, @poteat:
$.model.fork()shares the prompt cache (307k-token session audited for ~24k) but returnsnullon a cold snapshot — arm atsession.start, fire at the first warm moment.$.session.messages()is always the main thread, so I see what agents did, never what they said — where half the waste lives. Please expose them.Inputis hostile to steering: focusing a field you just opened is a multi-frame dance (open({focus})refused,autoFocusonly on a "take",$.ui.focus()refuses uncommitted elements), it's one line with no cancel, and pre-filling it hides keystrokes past the truncation — useplaceholder. Asks:autoFocuson next commit, Shift+Enter, Esc, andplugin testdriving focus/keys.MIT: https://github.com/AlmogBaku/ContextSaver
Feedback from porting a transcript-capture hook to a mod (v2.1.275,
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).What worked. A mod that uploads the session transcript to our own store at
turn.complete,session.compactandsession.endworked in-pand interactive runs. That covers/clear,/exit, resume, and sessions driven entirely by channel notifications, which our shellSessionEndhook handled badly./plugin-typesplustsccatching a misspelled event name made this pleasant.The gap: there's no way to get the transcript file path from
$. Classic hook inputs carrytranscript_path(BaseHookInput). The mod surface has$.session.id()("the transcript file's name") and$.session.messages().messages()is a summarized view: role/text/toolUses, newest 4096. It isn't the raw JSONL records (isMeta,origin, timestamps, attachments) that an archiver needs. So the mod rebuilds the path as$HOME/.claude/projects/<cwd with non-alphanumerics as '-'>/<id>.jsonl. That copies an internal naming rule. It's unclear whethercwd()orroot()is the right key after/cdor a worktree move. And it breaks if the layout changes.Asks, any one of which would do:
$.session.transcriptPath(), ortranscriptPathon thesession.end/turn.complete/session.compactinputs.messages().Two smaller notes:
session.endbound (1.5 s default) cut off a normal HTTP upload.$.env.set('CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS', …)insession.startraised it, and so did a settingsenvblock. That works, but it's undocumented on the mod surface. A per-plugin bound, or a documented way to request one, would be clearer.mods/types/claude-code.d.tsdoesn't listsession.end, but the 2.1.275/plugin-typesoutput does. We treated the local output as authoritative. Worth noting in the README for people reading the repo copy.Sharing a mod I built on function hooks: pi-agent-for-claude runs the pi coding agent as a native Claude Code subagent, so any model pi can reach can work inside Claude Code's own agent UI.
https://github.com/FazalAAli/pi-agent-for-claude
It registers a
pisubagent type and replaces only that subagent'sturn.stepwith a detachedpi --mode jsonrun. Claude Code still starts a real subagent, so it shows in$.agent.list(), streams text, thinking and tool calls live, takes follow-ups, and works as an agent-team teammate in a tmux pane. It reports pi's real token usage, and every answer is signed with the provider/model taken from pi's own events.Some feedback from building it, in case it's useful for the API:
pi_progress), and chain the run across steps. A supported way for aturn.stephook to run long, or to yield without a fake tool call, would remove that workaround.$.session.messages(), so I readsubagents/agent-<id>.jsonlunder~/.claude/projects/and poll for them. An API for a subagent's own transcript would make this robust.ps. Exposing those on$would help.Thanks for opening this up. Mods made something possible that plugins alone couldn't do.
@AlmogBaku
Indeed, we'll fix this. I think an optional
agentIdkey on the event would be reasonable.@ajwillia
Ideally, in the optimal case you don't need to read the raw transcript file - those can be very large and technically do not form a stable interface. ergo, I would want to default to making that information available on an actually contractual interface, if I can do so efficiently.
I believe this information may be present on
next.budget, although it's misleading for thesession.endevent right now, as there exists the separate criterion that we want exits to be fast no matter what mods are loaded. I'll ensurenext.budgetis relaying the correct time in this case.---
Separately to all this, we were able to get out AGENTS.md support as a built-in mod! https://x.com/trq212/status/2101009392611278961
Huge thanks to everyone here in this thread, who made this possible.
Crusty old Java guy here.
Is the ordered loading of mods completely deterministic? It absolutely must be.
Once custom project-instruction mods exist, two of them will eventually claim that slot. What decides which one wins?
I've only read the proposal thread, not 2.1.277, so I may be describing a version that no longer exists, so I may be off base. As written, the admin prepend/append lists give you a total order and the band between them looks like it relies on component-declared precedence.
Jakarta Config spent a long time on exactly that, and the argument is worth the ten minutes: component-declared precedence can only ever yield a partial order, because two independently authored sources can claim the same rank and become indistinguishable. Total ordering turns out to be an application-level concern.
https://github.com/jakartaee/config/discussions/8
MicroProfile Config's fix was one line: ties break lexicographically by source name. Not elegant, but total and reproducible.
@edburns
Yes. The order is always in the following order:
Where user order is some sort of deterministic merge of the user-supplied order and the order-per-topological-sort on a plugin-dependency level.
As an aside, every 'numeric priority' system in the world has failed on this, which is why I don't introduce any number levels at all. Raymond Chen said it better than I ever could.
I appreciate your wisdom here and I 100% agree; mods cannot dictate their own registration order, full-stop.
the 'a single hook on * sees every event, so an audit log is one function' bit is where this quietly stops being complete. since hooks nest like middleware in registration order, any plugin prepended before the audit hook can mutate or swallow an event on $ before the wildcard listener ever sees it. the log then reads clean precisely when an upstream plugin is the thing misbehaving, which is the one case you built the audit for. worth either pinning the audit hook's position in the chain or feeding it off the raw pre-composition event stream, so ordering can't blind it.
@m13v
Indeed, folks brought this up early on on the thread and we hence introduced
next.traceto close this gap; which among other things is a collection of the timings, event snapshots, and return values across the chain for hooks registered below you. re pinning, plugin registration order is the prerogative of the org admin; it would probably be _wise_ for them to register it early in the org-prepend list, but that's up to their discretion.Two findings from building mods, on 2.1.277 and 2.1.278:
1. In a subagent,
next(e)resolves withresult: undefined. A main-loop Read returns the structured record (result.type === "text",file.startLine, ...). The same Read from a subagent hasresultundefined;textandrefare still set. The types say core returns{ ref, result, text }. My mod readresult.file.startLine, threw inside subagents, and the hook was skipped with no trace in the transcript or-poutput. Could subagents get the sameresult, or the types say it's absent?2.
claude plugin validateshows a variable matcher as a literal.for (const tool of ["Bash"]) on("tool.call", { tool }, ...)is listed astool.call{tool=tool}, but at runtime it matches Bash correctly.Also, re @fragmentsstudio's 2.1.274 note: with
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1,tool.callandsession.compacthooks fire underclaude -pfor me on these versions.@musharna
Good find, these check out - subagents should get that
result, it was a data flow in the engine. re (2), yeah I'm thinking that ought generally be displayedtool.call{tool=?}but I think I can safely have a matcher for the particular syntax of looping over a literal array.@poteat, thanks for asking for feedback on the test kit in #95423. My interest in Mods comes from building and operating a broader system around Claude Code's shell hooks, lifecycle and customisation surfaces.
Dyna-UI / ClaudeCodeStatusline is the public-facing example: it brings direct picker interaction and Claude-guided configuration together through the renderer's own registry and previews.
Behind that is ANVIL / OneTree, where Claude and Codex sessions share workflow infrastructure, with Coordinator V3 providing explicit owner/parent coordination and an
anvil-coordinatorMCP channel into Claude. The areas most relevant to Mods are:What I could bring to the Mods effort is practical implementation experience with those boundaries, concrete cases for lifecycle and composition design, and evidence for evaluating the resulting behaviour.
Some of the underlying work is proprietary. If this experience aligns with the team's priorities, I'd welcome a private conversation about the relevant implementation, supporting evidence and a suitable scope for collaboration. Please feel free to reach out at [konsta.pukari@gmail.com](mailto:konsta.pukari@gmail.com) or on LinkedIn.
Something concrete on the kit, out of #95423. That PR's
mayHaveWrittenreadsisReadOnlyoff the shell tool'stool.callresult, and its test passes because the fixtureREAD_ONLY_ANSWERsupplies the field: a test'son('tool.call')sits beneath every plugin with no process behind it, so inside the kit the test is the producer. Whether the engine produces the field is a question the kit cannot ask. Measured outside it, on 2.1.278 and 2.1.274: no Bashtool.callresult carriesisReadOnly. In the 2.1.278 binary all 92 occurrences of the name are the tool descriptors' method definitions,tool.isReadOnly(input)calls, or the name as a string; none is a data value.The instrument is a three-file mod.
session.startcalls$.tool.call({ tool: 'Bash', command: 'ls' })itself, with no model in the loop, and atool.call{tool=Bash}hook records the keys of every answer beneath it. It carries its own control: a failinglsshowsisError: truearriving, so an absent field is not a blind observer. About five seconds a run on Haiku.<details>
<summary>probe (passes <code>claude plugin validate</code>)</summary>
.claude-plugin/plugin.jsonhooks/hooks.jsonhooks/probe.tsRun from the plugin directory, then read
probe-out/*.json:What it wrote on 2.1.278:
</details>
Two kit suggestions from this. A recorded-core mode: capture the engine's actual answers for the calls a test makes, once, from a live session, and replay them as the tier beneath the test's hooks, so a fixture like
READ_ONLY_ANSWERis either a recording or visibly hand-written. And a check of a test hook's answer against the declared result shape, or at least a warning on a field the declarations do not name, so anObject.assignaround the type is visible. @musharna'sresult: undefinedin subagents above is the same gap from the other side: the kit cannot see what the producer actually does.Thanks for that Raymond Chen link. I'm happy to observe he is still working here. We've let so many good ones go.
I have two focused fixes for the published built-in Mods, with regression tests, and would like to contribute them. This repository currently restricts PR creation to collaborators; my account cannot open a PR. Is there a preferred submission channel for these patches, or could a maintainer accept them through a collaborator-owned PR?
diff: forced Git colors produce an empty diff body. Withcolor.ui=alwaysorcolor.diff=always, change counts remain correct but ANSI escapes prevent hunk headers from being parsed. The patch adds--no-colorto the shared diff arguments and a regression test. Reproduced with real temporary Git repositories; all four Mod suites pass on Claude Code 2.1.278 (207 tests).Patch comparison · commit
fa775ed9c9f332c20c8c0e4898cd56b7fccd5a73agents-md: an automatically paginated Read is marked fully delivered. An unbounded Read can return truncated instructions, but the mod marks the whole file as sent and omits its unread tail on subsequent source-file reads. The patch checks the returned truncation flag and line range before recording delivery. The failing regressions now pass; all four Mod suites pass on Claude Code 2.1.278 (212 tests).Patch comparison · commit
e704c658ec891ebeb28a6d274f34b4c1dd4e1cf5Each patch changes only its implementation and regression test. TypeScript checks, plugin validation and
git diff --checkpass for both. The agents-md tests use structured Read responses matching the public API contract; neither patch has been verified in an interactive model session. The patches and tests were prepared with AI assistance.Please let me know whether these fixes are in scope and how you would prefer to receive them.
Three engine observations from building a
/diff tmuxvariant onmods/diff, all on 2.1.278, measurements below.A hooks module links at most 512 files, and the refusal names an unrelated one. Past 512 the loader drops the whole module (
<file> is past the 512 files a hooks module may link and was not read), and the file it names is an unchanged file elsewhere in the module, not the one that was added.mods/diffon main (b486776) links 506: a seventh added file fails, naminghooks/array-of/array-of.ts. On #94847's head (23a906a) it links 511: a second added file fails, naming the samearray-of.ts, and a third nameshooks/git/tiers/with-untracked/merged-with-untracked/merged-with-untracked.tsinstead, so the name follows the walk, not the change. That branch met the cap once already (3dbda69: two files from a main merge took it to 514, reshaped to 511). The number is in neither the.d.tsnor the docs (git grepover main for the message text finds nothing), and a helper in the module's shape, a folder with its index barrel, is two files: main has room for three, the PR branch for none.Load order, one line. A hooks module the size of
mods/diffis evaluated 3 to 4.4 s after the process starts while the input prompt is live at 1.7 s, and a slash command submitted in that gap is answered by the built-in alone: the module'scommand.runhook on that name is not dispatched for it, and itssession.startlog line lands after the built-in's reply (main's own module with two log lines added, no other change; a one-file module is evaluated at 0.95 s, before the prompt, and its hook fires for the same call). Related, since the built-in holds/diff,$.command.register({ name: 'diff' })is refused as the.d.tssays, so a variant of a built-in lives on acommand.runhook on the built-in's name, which is the hook that gap does not reach.<details>
<summary>measurements</summary>
Cap: extract
mods/diffat a ref, import a barrel that imports N one-line files, runclaude plugin validate, take the first N that fails (files added = N + 1).| tree | passes with | fails with | linked | file named on failure |
| --- | --- | --- | --- | --- |
| main b486776 | +6 | +7 | 506 | hooks/array-of/array-of.ts |
| #94847 head 23a906a | +1 | +2 | 511 | hooks/array-of/array-of.ts (+3: hooks/git/tiers/with-untracked/merged-with-untracked/merged-with-untracked.ts) |
The constant sits in the binary as
eo=512beside the message template.Load order:
claude --plugin-dir <mod>on a private tmux server from a git repository,/diff tmuxsent once the prompt is on screen and Enter 0.1 s or 0.7 s later. The module carries$.ui.logat the top ofsession.startand in itscommand.runhook,Date.now()at module top level, and a file write of that and the hook's own time fromsession.start. Screen after the first call, main'smods/diff:No
command.runline for that call; the second call logsargs="tmux" host=false. Prompt on screen at 1.67 s; module evaluated at 3.02 s and 4.37 s after start in the two runs (2.5 to 3.8 s inclaude -p, three runs). A one-file module hooking the same name: evaluated at 0.92 to 0.95 s, and itssession.startandcommand.runlines both sit above the first reply at either delay.</details>
One more engine observation, from the status line mod, on 2.1.280, measurements below.
A hooked
PromptHintdraws one frame with the engine's own hint row stacked over the hook's previous tree, every time it is dispatched again. Each newui.renderdispatch of the line under the prompt, whether a props change (esc to interruptappearing or going away) or the plugin's own$.ui.invalidate('ui.render'), first commits a frame whose footer is two rows: the engine's default hint row for the new props, pill included, and under it the tree the hook answered last, with the prompt box pushed up a row to make room. The next frame has the hook's new answer on one row again. On screen that is a copy of the plugin's line blinking one row below the pill. It shows most when typing during a turn, because the first key into the empty prompt hidesesc to interrupt, which is a props change. It does not depend on the hook: one that answers synchronously with fixed text shows it at every change of the hint and, asking for a redraw every 500 ms on an idle prompt, in 35 of 79 frames, and with no hook on the site the same turn draws none. The typings don't say what the line shows while a new answer is pending; holding the previous answer on its one row, as a repaint does, would remove it. A plugin can only lower its own share: the status line mod asked for a redraw after every refresh, and asking only when what it draws has changed took a six-tool-call turn from 16 such frames to 7 and 9 in two runs. The hint changes remain.<details>
<summary>measurements</summary>
The probe is one
ui.renderhook; the "timer" rows add thesession.starthook:Method:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir <probe> --model claude-haiku-4-5-20251001on a 280×69 pty in the fullscreen UI, the other mods installed here disabled, with every byte the process wrote recorded, replayed through pyte and cut into frames at eachESC[?25h(the renderer brackets a frame inESC[?25l ESC[H…ESC[?25h, no DEC 2026). A frame counts when more than one non-empty row sits under the prompt box's bottom rule. The six-tool turn is one prompt asking forecho onetoecho sixas six Bash calls, under--allowedTools Bash(echo:*). The typing session sends a prompt for a 1500-word story and, while it streams, typeshello there,world, three backspaces andabc.| hook on
PromptHint| session | frames with a two-row footer || --- | --- | --- |
| none (a module hooking only
session.start, passing through) | six-tool turn, two runs | 0 of 202, 0 of 172 || probe | six-tool turn | 4 of 171, each at a hint change |
| probe | idle | 1 of 11, the startup mode change |
| probe with the timer | idle | 35 of 79 |
| probe | story, typing during it | 5 of 96, the first keystroke among them |
Each lasted a single frame, once two, in fifteen recordings. The first keystroke during the story, bottom rows of frames 28 to 30 (rules shortened):
With the prompt holding text, the turn's end drew one too with nothing visible changed, the probe included: of the three
PromptHintprops, onlyisWorkingmoves there.</details>
One more from the status line mod, also on 2.1.280, measurements below.
After Esc interrupts a turn,
$.session.usage()answerscontextwith notokensorpercent, where the classic status line payload keeps its figure.turn.completearrives withisAborted: trueandreason: 'aborted', and the first$.session.usage()read after it haswindowalone, 49 ms after a read at the step's end hadtokens37393. The typings calltokensthe status line'stotal_input_tokensand keep its absence for "a fresh session, or one just compacted, until its next response"; neither is the case here, and the classic payload sent at the interrupt still hadtotal_input_tokens37191 andused_percentage19. So a line drawn from the noun loses its figure after an interrupt, and one drawn from the classic payload does not. The count stays missing until the next response arrives, the next request's first step included. The status line mod now keeps the figure it had whenturn.completesaysisAborted; the gap at the next request remains.<details>
<summary>measurements</summary>
Haiku 4.5 on a 280×69 pty, the status line mod with a trace of every
$.session.usage()read,turn.stepandturn.complete:| session: a 1500-word story as the prompt, keys typed into the prompt while it streams | the read after
turn.complete|| --- | --- |
| left to finish, 6 runs |
tokenspresent, 6 of 6 (37088 to 37110) || interrupted while it streams by the driver's closing Esc and ctrl+c, 6 runs |
tokensabsent, 6 of 6 |A short turn, then the story interrupted with Esc (ms since the module loaded):
The same two turns with the story left to finish:
reason: 'answer', and the read after it hadtokens37371.Classic payload: the same two turns, the story interrupted with Esc, with a module that hooks nothing that draws and
statusLinerunning a script that saved every payload. The last of the five, sent at the interrupt, still hadcontext_window.total_input_tokens37191 andused_percentage19, the figures it had carried since the story's request began.The next request, with the mod keeping its figure after the interrupt: the screen 0.8 s after that prompt was sent showed
0/200K, the one 0.8 s later the new figure.</details>
Feature request: let mods trigger the existing microcompact (tool-result clearing) on demand.
Context: I run a Pi extension (condensed-milk) that masks stale tool results per request: old tool
outputs are replaced with short placeholders below a cutoff that only moves when context crosses
fixed token thresholds (80k/120k/160k/...). That costs one prompt-cache miss per threshold and is
byte-stable in between. Replayed over ~100 of my long sessions: ~30% fewer prompt tokens, 0 extra
cache misses between threshold events. Live resumes: 200k -> 70k context, 99.6% cache hits after
the one miss.
Claude Code already has the mechanism: the context-hint microcompact clears all but the most recent
tool results, spills them to files ("Tool result saved to ... Use Read to view") and records a
microcompact boundary. But it only fires when the server asks for it, so a mod can't use it.
Ask, either of:
$.session.microcompact({ keepRecent?: number })returning{ clearedIds, tokensSaved }, callablefrom e.g.
turn.completeafter checking$.session.usage; orsession.microcompactevent mods can intercept and trigger, with keepRecent configurable.This gives mods threshold-based, cache-aware context control without unpinning per-request messages, which I understand is a bigger change. Thanks!
@konsta95
Good call, I think a file limit is inappropriate and arbitrary, this will be removed. As an aside, thanks for playing around the
diffmod - I hope to swap the hard-coded one out for the mod version soon, but as you surmised it needs a bit of additional work to reach full parity.@7487
Indeed, we support plenty of source contributions on the sandbox-runtime, so theoretically this has prior art. Let me do some tracking down about this decision.
Love the idea of mods!
I built a mod that lets me add and manage envs directly within CC. It’s probably still a bit rough around the edges, but I thought it might be a good starting-point for a built-in mod :)
https://github.com/tre-dev/claude-env
PS: In the video, the 2nd time I enter the secret is via pasting
CLAUDE_IS=AMAZINGhttps://github.com/user-attachments/assets/4ab5b14b-4696-45ad-b72b-c656584c6025
Shipped a mod on 2.1.282: topic-filter. It swaps chosen words, topics and tagged repos for placeholders in everything the model reads. A couple notes:
turn.stephas no messages, so it took hooks on ten events. One hook on the outgoing request would be simpler.booleanuserConfig on the/pluginconfigure screen is a text box, and typingysavesfalse./configgets it right.claude plugin testcan't set userConfig values.Following up on my comment above: thank you for
prompt.editin 2.1.280. We are integrating OpenCues as a mod on it now, retiring our binary patch.prompt.editreads the draft and takes the_,PromptHintdraws the note under the box, and$.model.completewrites cues on the session's own model, so the person needs no key of ours. Everything fits the API as shipped, with one gap.A mod cannot style part of the draft.
PromptEditResultisPromptBox,{ text, cursor }, and no render site covers the input itself, so a mod can say what it noticed but cannot point at it. On our other hosts the word a cue is about, the sentence a rewrite would replace, or the command waiting in the draft is tinted in place (OpenCode through OpenTUI extmarks, the browser through the CSS Custom Highlight API). In Claude Code the note has to describe where it is instead.The smallest version of the ask: an optional
decorationson whatprompt.editanswers, and onPromptFillArgsfor a write that lands after the keystroke (a model answer 300 ms after the_).text, styles from theTextPropsallowlist you already have.sec-defaultcan strip them like the other$.prompt.*powers.With that, the mod would be at parity with our other hosts.
@lperezmo
This is by design, because I was worried about performance - since we send every message up on every request, these can get pretty large, and if you accidentally loop through the full message sequence on each request sent, your total amount of work will be quadratic.
However, you should be able to see the things that the model reads - We're thus adding a
session.appendwhich represents "appending something to the session".@wkasekende
This is a sensible ask, it sounds like a marks system. We'll queue this up.
Surely we will do better this time. https://en.wikipedia.org/wiki/Browser_wars
I have a few UI-related requests based on two mods I'm building:
/commits: a pane with a navigable commit list, and comment on any part of the commit in the pane. The comment and selected lines go back to the model in the prompt. Inspired by native/diff, which has nice features like text selection and composer pill./learn: present Claude Academy content in situ, based on the user's activity, to teach new and relevant features.Just aside for mod development --
I was experimenting with
mods/diff, and didn't immediately notice it didn't supersede native/diff, despite loading the mod (as mentioned). It would be helpful if mods colliding with built-ins could be used, and/or if Claude Code emitted a warning if a name is already used. (As a workaround, I renamed the command todiffm.)Requests
$.prompt.fill.ui.selectionevent, at least for selections inside the mod's own pane.$.prompt.attachmethod that adds composer pills carrying context.Details and speculative usage from Claude:
1. A mod cannot get keyboard focus back after
$.prompt.fillOnly a fill from a
Clientregion keeps focus, which means routing every key through a region and giving up the focus ring, hotkeys and Button styling.Request
A
$.uimethod that returns the keyboard to the mod's pane, or a$.prompt.filloption that leaves focus where it is.2. A mod cannot observe a text selection
The native
/diffsidebar reads the terminal selection inside its own pane, which is how dragging over lines includes them with the next prompt. A mod has no such access. AClientregion gets pointer coordinates for drags inside itself but never the selected text, so the commits mod draws its diff in a region and maps rows to lines itself, which also switches off the terminal's own selection and copy there.Requests
ui.selectionevent when a drag there ends, with the text and row range, as the native sidebar has.``
ts
``// the diff could be plain Text in the pane instead of a Client region
on('ui.selection', { requestId: Names.PANE_ID }, ($, e) => includeLines(e.rows.from, e.rows.to))
$.session.messages(), so it could be left for later.3. A mod cannot create a composer pill or an attachment
A mod can only write editable text into the prompt draft with
$.prompt.fill, which loses focus (section 1), andprompt.attachmentonly rewrites attachments the engine created. The native/diffsidebar, by contrast, never edits the prompt draft:Request
A
$.prompt.attachmethod that adds labelled pills carrying context, several at once, cleared on submit or deletion, without editing the prompt draft or moving focus.4. The pane close mark differs from the native panel's
The mod pane's close mark is a
DimButton: inverse on hover and press, and a stop in the focus ring.The native sidebar's is a plain Box that goes from dim to bold on hover and is not in the ring. A mod can neither restyle the mark nor take it out of the ring, so the commits mod has a
ui.focushook that catches Tab landing on it and wraps to a commit. Escape (closeOnEscape) already closes the pane from the keyboard.Request
Draw the pane's close mark as the native sidebar does, out of the focus ring, or add a
$.ui.openoption for it.5. Buttons cannot bind keys of their own
The commits pane has two things to navigate: the commit list and, below it, the selected commit's content. The arrows scroll the content, so moving through the list falls to Tab and to ctrl+↑/↓. The mod gets those chords from two invisible Buttons whose
actionborrows the native diff sidebar'sapp:diffFileListUpandapp:diffFileListDown, and with both panes open the engine gives the chords to whichever was drawn last. AButtonotherwise takes only a one-characterhotkey; arrows, modified Enter and chords need the person to bind an engine action.Request
A method that registers a mod's own keybinding actions, with default keys in the
Panecontext, for Buttons to name.6. A mod cannot see or add to the spinner tip
The spinner row is a
Spinnercomponent: a hook can rewrite itsword,messageandsuffix, or draw its own tree. The tip line under it is separate. It is drawn from the app statespinnerTip, so a mod cannot read, replace or hide it, and no event marks a tip shown. ThespinnerTipsOverride.tipsFilesetting is honoured only from user,--settingsand managed settings, and is read once per process. That covers an organisation's own tips, with their own label, but a plugin can neither ship tips nor show one about what just happened. Tips are where Claude Code teaches features during a turn, which is what a mod that suggests features needs.Claude Code's own tips can already come from a mod: the built-in
tipsplugin, behindtengu_tips_mod, picks a tip each turn and shows it through a host interface that installed mods do not get.Requests
tipprop onSpinner, or aSpinnerTipcomponent, that a hook can read, rewrite or hide. A hook could then draw anything there, not only tips.``
ts
``// after seeing the model poll with sleep
$.ui.tip({ id: 'loop', text: '/loop 5m <check> replaces polling with sleep' })
An API request from building a mod: let a plugin start a compaction from code, for any loop, mid-turn.
We maintain sub-agent-compact, which gives the main session and each subagent its own compaction policy. The next version goes further. The model is nudged as its context grows, through
tool.callcontext. It picks its own milestone to compact at, and gives a focus that becomes the summarizer'sinstructions. All of that works through function hooks except the one step only core can take: starting the compaction.$.session.compact()compacts only the main conversation, between turns, and it rejects while a turn runs. A subagent always runs inside a turn, so a plugin has no way to compact one. The only lever is vetoing core's own asks, which forces this workaround:CLAUDE_CODE_AUTO_COMPACT_WINDOWlow for the whole process. It isn't a policy. It only sets the point past which core askssession.compact(auto) before every request.{ skip }to every ask until the party reaches its own point, or until its model asked to compact.instructions.It works, with two costs:
The ask:
It would run through
session.compactwithtrigger: 'plugin'ande.agentIdset, as it does today. Called from atool.callorturn.stephook, it would take effect at that loop's next request boundary instead of rejecting. With it, the window override and the veto dance go away, and a party's policy lives in one place.A related finding from the same work, on 2.1.283: a tool registered with
$.tool.registernever reaches a subagent whose definition has atools:allowlist. The main session sees it, but the allowlisted agent doesn't. So for those agents, the model's way to ask for a compaction has to be a text marker scanned inturn.step. A registration flag that offers a plugin tool to every loop, or anagent.spawnfield that adds tools, would close that side too.Made a mod that puts pixel art above the prompt and reacts to what Claude is doing.
It also just adds more personality and flair to your workspace!
<img width="808" height="398" alt="Image" src="https://github.com/user-attachments/assets/36b0b786-e2de-4671-b431-4096bb071d1b" />
Currently there are seven animated scenes (Michelangelo's hands with a spark between the fingers, a rainy city at night, matrix rain, an aquarium, space, aurora, fire), or you can drop in your own image or GIF. While Claude works the scene picks up, like heavier rain or the spark crackling, and when it finishes you get a little flourish. It shrinks out of the way during turns and costs zero tokens.
claude plugin marketplace add furqan-khan07/pixelband
claude plugin install pixelband@pixelband
Then /pixelband opens a menu to pick a scene or image.
https://github.com/furqan-khan07/pixelband
ui.renderhooks run more often than the typings describe, in two cases. I hit both while building a mod with two panes. Measured on Claude Code 2.1.283, macOS 26.3, in the terminal's fullscreen UI. Full repro and logs are at the end.1. One plugin's
$.ui.invalidate('ui.render')re-runs another plugin's Pane hook.Plugin A hooks only
{ component: 'Pane', requestId: 'src' }. Plugin B draws panebyand never calls invalidate. Withbyon screen, each invalidate from A re-ran B's hook 3 to 7 ms later with the same props. That was 9 of 9, across three runs. Commands that don't invalidate, and 5 s of idle, caused no renders.The typings say invalidate "draws the instances this plugin may draw again". A's matcher can never select
by, so I expected B's hook to stay quiet. As it works today, a mod that invalidates on everytool.callmakes every other mod's visible pane render again, and moving panes into separate plugins doesn't isolate them.2. The first pane opened in a session renders twice with the same inputs.
Its hook runs twice, 63 to 88 ms apart, with identical
propsandviewport. That happened in 5 of 5 runs, whichever plugin's pane opened first. Panes opened later render once. No invalidate is involved, since the plugin only calls$.ui.open. The typings sayui.renderfires "once per input value (props, viewport width), plugin load or$.ui.invalidate", and the second run matches none of those.The two have different triggers, so they may not share a cause. Both mean a hook that does real work pays for renders nobody asked for. I'd like invalidate limited to the instances the caller's matchers select, or a scope argument such as
$.ui.invalidate('ui.render', { requestId }). For the second, either one render at open or a line in the typings saying why there are two.<details>
<summary>repro and measurements</summary>
Two plugins, three files each.
.claude-plugin/plugin.jsonis{ "name": "inval-src", "version": "0.0.1" }, orinval-byfor the other.hooks/hooks.jsonis{ "modules": ["./register.tsx"] }. The modules:src/hooks/register.tsxby/hooks/register.tsxSteps. Use a trusted folder, since hooks modules don't load before workspace trust, and a terminal at least 110 columns wide in the fullscreen UI so the panes dock.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./src --plugin-dir ./by --debug-file debug.log/srcopen, then/byopen.byis now on screen andsrcis a tab./srcnooptwice as a control, then/srctimer, and wait about 8 s.grep REPRO debug.logI ran these steps from a script through a 200×50 pty, with my other mods disabled. Five runs: three as above, one with the two opens swapped, and one earlier version that invalidated from a command instead of the clock.
Log from the last run. I trimmed the lines, shortened props, and added the
#notes.Other things I saw:
srcwas on screen,byrendered twice at open, 88 ms apart, andsrconce. The three invalidates then redrewsrceach time and neverby.claude plugin test, a plugin's invalidate re-ran every mountedPane, including one drawn only by the test's own hook and one mounted under another plugin's name. It did not re-run a mountedTurnDuration, which the plugin doesn't hook. So the scope looks like every instance of a component the plugin hooks, withrequestIdignored.</details>
Feedback from trying to build a mod for the desktop app's Code tab (Windows, desktop 2.9939.2.0, engine 2.1.281, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1):
session.start surface=null interactive=false, and noui.renderarrives, with components hooked atAbovePrompt,SessionMode,Paneand underAssistantMessage. The same mod draws fine in the terminal.app.asarhas noui_render/ui_press/AbovePrompt, so I take it the Code tab doesn't implement the render protocol yet.The mod was a row of quick-command buttons above the prompt. For the desktop I ended up with an external overlay instead (https://github.com/seosy218-ops/claude-quickbar), and filed the feature itself as #97860. Is desktop rendering for mods on the roadmap? Once
AbovePromptdraws in the desktop, the overlay can become a mod.Mods: let a mod read the transcript selection (for quote-reply)
Use case: quote part of Claude's reply and respond to it. Highlight a sentence, press
>, and the prompt gets> {sentence}on a new line, ready for a reply. Today this takes a copy, a paste and typing>by hand.Ask: expose the current transcript selection. Keys pressed while text is highlighted already reach
prompt.edit(tested on 2.1.284), so the selected text is the only missing piece. Either of these works:With that, the mod is a few lines:
Nice to have:
Related: #67427 (the same workflow in VS Code, closed as stale) and #95144 (a Reply shortcut in Desktop).
Ask: when a plugin ships
modulesand function hooks are off, say so.Today a mod-only plugin installed without
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1loads nothing and reports nothing: no line at startup, nothing in/plugin. Every mod README carries a "set the flag first" line, and a user who misses it has a plugin that looks installed and does nothing. Something likemy-plugin: hooks module skipped, function hooks are disabledat session start, or a status in/plugin, would close it.Workaround until then: a classic hook in the same plugin reports it. Classic hooks run with the flag off, and
hooks.jsonacceptsmodulesandhooksside by side, so the plugin can check the flag itself. No launcher, wrapper or doctor command is needed; it works for everyone who installs the plugin.hooks/hooks.json:hooks/check-mods-enabled.sh:Details that matter:
systemMessageis the line the person sees.additionalContexttells the model, so when someone asks "why didn't the guard fire?" it answers from the session instead of guessing.${VAR:-}underset -u. An unset flag is the case being detected, so a bare$CLAUDE_CODE_ENABLE_FUNCTION_HOOKSwould abort the script instead of warning.${CLAUDE_PLUGIN_ROOT}. A plugin root with a space in its path still resolves to one argument.Observed on 2.1.283/2.1.284, macOS, interactive, flag toggled only in
settings.jsonenv(nothing exported from the shell profile):claude plugin validateaccepts the combinedhooks.json; flag removed → the warning shows and reaches the model; flag restored → no output. So thesettings.jsonenvvalue does reach classic hooks' environment, which the silent-when-on half depends on.Ask: make this work in cloud sessions.
Cloud sessions are the way I work primarily. My desired functionality is to show preview URLs for Vercel deployments (and some related stuff), ideally in a line above the prompt. Something similar to https://github.com/ray-amjad/awesome-claude-code-function-hooks/tree/main/plugins/vercel-deploy-status with appropriate modifications for my workflow. I have more ideas after that but this would be the biggest win. Currently I have a combination of hooks / agent instructions that watches CI and prints those URLs once the build finishes (we deploy to Vercel using GitHub Actions), but that just prints them at the end of that turn and then I have to go hunting for them if I need to see them later in the session. Claude mod would be ideal for this.
After some back and forth with Claude, it looks like there are a couple of blockers. First, even to run plugins in cloud sessions is not easy. Claude managed to do it via hot-reloading, but that is a hack and not sustainable for real use. Second, while it managed to get the test mod to print out a notice row via
$.ui.log,$.ui.renderand related things didn't work, so the desired functionality could not be achieved.Here is the full report from Claude: https://claude.ai/artifact/MWPARXCBAyNL9T3727YvQK?sk=wqCDibE3DqqNBv9_oQkERw#e717ceaa-466a
Thanks!
https://github.com/anthropics/claude-code/issues/92533
We've launched! Now the real work begins.
@seosy218-ops
Thank you so much for trying to build on desktop early; we should broadly support logic and UI customization in Claude Code Desktop as of today. Let me know what you think and if it works for you!
@nogu66
I believe we've fixed that in v285 - it was a reactive rendering issue related to dynamic width.
@dkokotov
I'm also a big fan of our cloud support and it's within our scope to get this working asap.
Built a PR review inbox as a mod (pane + band, terminal and Desktop). Terminal on 2.1.287, Desktop bundling 2.1.286. Terminal held up well. Notes from Desktop and the element set, bugs first.
Bugs
Clientsurface module never loads on Desktop. Pane showsClient hooks/codeview.js: did not load within 10s (a content security policy may have refused its runtime, or the module's top level never settled)and the log hasClient frame torn down. Same module loads and takes pointer events fine in the terminal. Tried it as.tsand as plain.jswith no imports and a trivial top level, same result.Clienton Desktop".Codeelement withformat: 'diff'that isn't a valid hunk blanks the whole pane. I truncated a long diff mid-hunk and got an empty pane with nothing on screen, reason only in the debug log. Refusing that one element, or drawing the error in its place, would've saved a while.if (e.isInteractive)insession.startruns in the terminal and never on Desktop. Starting it from acommand.runhook works, so I'm guessing Desktop still reportsinteractive=false. Didn't confirm the value./reload-pluginsalone didn't pick it up, needed uninstall + install every time.Essentials I missed
Input. One line with no line breaks is rough for anything longer than a few words, and on Desktop the field stays a short box so editing a long comment is painful. In the terminal it at least wraps as you type.Codeon Desktop always draws as a card with a copy button, and a diff shows its raw@@header with no line gutter. No way to turn that off, so I ended up drawing diffs as anSvg, which loses syntax colours and can't be clicked.Buttonand Markdown links are pressable, and aButtonlabel is plain text. A list row can't be one clickable thing with a chip or a dim part inside it.claude plugin testchecks the tree, not the paint, so every Desktop look was a screenshot round trip.Happy to share the mod or a minimal repro for the
Clientone if that helps.Built a phrase bar as a mod (one row of buttons in
AbovePromptthat send common prompts likecontinueor/compact). Desktop 2.19675 bundling 2.1.286, Windows 11.Bug:
$.command.run({ command: 'compact' })shows no compacting indicator on DesktopWhen you type
/compactin the Code tab, the native "compacting" indicator shows up right away. When a plugin runs the same command with$.command.run({ command: 'compact' }), the conversation does get compacted, but no indicator ever appears. Nothing on screen changes until the result row arrives at the end. People assume the click didn't register and press again.Minimal repro, one button above the prompt:
Expected: clicking it looks the same as typing
/compactand pressing Enter. Actual: no indicator until compaction finishes.Workaround on our side: the button shows
/compact …until$.command.runresolves and while asession.compacthook is running, and repeat presses are ignored in the meantime. That stops the double-compacts, but users still don't get the app's own progress indicator.Another one from the same phrase-bar mod. Desktop 2.19675 bundling 2.1.286, Windows 11, Microsoft Pinyin.
Bug: a redraw of a plugin's
Inputmid-composition breaks IME input on DesktopIf the tree holding an
Inputgets redrawn while an input method is composing a word, the composition is cancelled. With Microsoft Pinyin, typingnihaoputs the lettersnihaointo the field. The candidate window either never shows up or closes right away. Pasting Chinese works. The usual way to write a form, whereonInputwrites$.stateand the render hook reads that state back, redraws on every keystroke, so it hits this every time.Minimal repro: a pane with two fields. Type pinyin into each.
What we narrowed it down to:
valuealone is fine, andonInputalone is fine, including one that writes state nothing reads. It only breaks when that write triggers a redraw. Expected: a redraw leaves a composition in progress alone, the way a controlled DOM input does with React.Workaround on our side: keystrokes go into state the pane never reads. The pane draws from a copy that only updates on other actions, like picking a mode.
Plugin
tool.callhook breaks worktree isolation for isolated subagentsVersion: Claude Code 2.1.287, macOS. It reproduces interactively and with
claude -p.Summary
When a plugin hooks module loads in the hooks worker and registers a
tool.callhook whose matcher matches a tool call, that call runs without the isolated agent's working-directory override. This happens with both the Agent tool'sisolation: "worktree"and a workflow'sagent({ isolation: 'worktree' }).The effects:
path, against the parent session's checkout rather than the worktree.Built-in plugins'
tool.callhooks, which run on the main thread, are not affected.Steps to reproduce
hooks/hooks.json:``
json
``{ "modules": ["./register.ts"] }
and
hooks/register.ts:``
ts
``import type { Register } from 'claude-code'
export const register: Register = on => {
on('tool.call', ($, e, next) => next(e))
}
claude --plugin-dir <plugin>and send:``
``Use the Agent tool once: subagent_type general-purpose, model haiku, isolation "worktree", prompt "Run pwd with Bash and report the output or error verbatim. Do not retry."
on('session.start', ($, e, next) => next(e))and repeat.Expected Result
The agent's
pwdprints<repo>/.claude/worktrees/agent-…, as it does in the control experiment.Actual Result
| Hook | Mode | Isolated agent's
pwd||---|---|---|
| broad
tool.call(no matcher) | interactive and-p| refused: "isolation context … was lost" || none (control) | interactive and
-p| runs in the worktree ||
tool.callwith a literal matcher for an unrelated tool,{ tool: 'mcp__<plugin>__noop' }|-p| runs in the worktree || broad
tool.call, launched withCLAUDE_CODE_HOOKS_SAME_THREAD=1|-p| runs in the worktree |With
--debug, the failing run logs[worktree] blocked shell exec after cwd-override losswhile the hook'stool.calldispatch is in flight ("worker hop, next() included").In one run, the agent then called
EnterWorktree({ path: <its own worktree> }). The tool reported the main-session result ("The session is now working in the worktree"), and the parent session's working directory moved into the agent's worktree.Likely cause
The names below describe roles, not identifiers.
next()comes back to the main thread as a worker message. The message handler resumes the stored continuation inside the hooks-scope context only. So the rest of thetool.callchain, including the tool's own execution, runs outside the agent's cwd store.$operations use it.nextdirectly, so they keep the context.claude plugin testruns hooks modules on the same thread, so plugin tests can't catch this.Suggested fix
nextcontinuation to the dispatcher's async context when the call is handed to the worker, for exampleAsyncResource.bind. Resume streaming hops inside that resource's scope too.EnterWorktreeandExitWorktreedecide "subagent-ness" from the tool-use context (agent id / agent worktree), not from the async store alone.Read,GlobandGrepresolve against the tool-use context's session cwd, as Bash does, so a lost store can't silently redirect them.Workarounds
tool.callhook a literaltoolmatcher that leaves out the cwd-sensitive tools: Bash, PowerShell, Monitor, Read, Glob, Grep, Edit, Write, NotebookEdit,EnterWorktree,ExitWorktreeand Agent.next().$.tool.callfrom the hook doesn't help: it runs in the main session's context.CLAUDE_CODE_HOOKS_SAME_THREAD=1. That variable looks internal, so it's not a supported fix.On 2.1.287, mods stay off for users without an Anthropic account (LLM gateway via
ANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN), although the docs say they're on by default.What I see (Windows, native):
claude plugin test→hooks modules are turned off in this process: the rollout switch served offN mods activeline in/plugin~/.claude.json→cachedGrowthBookFeatures.tengu_plugin_hooks_modules: falseThe client default is
true, but the unauthenticated GrowthBook eval returnsfalse(source: defaultValue), and that value lands in the disk cache. So a gateway user with an existing cache silently loses mods, while a fresh install (no cache) gets them. Deleting the key brings mods back (1 mod active · …), but the next anonymous refresh may set it back.Could unauthenticated clients be served
true, or at least not have the documented default overridden?Feedback: three gaps I hit with background agents on 2.1.287.
agent.spawndoesn't fire for it, and the Agent result (status: "teammate_spawned") carriesagent_id: "<name>@session-…", while its loop'stool.callandturn.completecarryagentId: "a<name>-<hash>". The only bridge I found is matching$.agent.list()'snameto the call'sname. Could the result, oragent.spawn, carry the loop'sagentId?$.agent.list()'sdescriptionis the first ~40 characters of the prompt, not the Agent call'sdescription. For anything that displays agents, the short description is what people recognize.status: "running"in$.agent.list()after they finish and go idle, so the list can't tell working from idle.SubagentStopandturn.completedo arrive with the loop's id, which is what I rely on.Smaller: in a repo that also has
.claude-plugin/marketplace.json,claude plugin validate --strict .validates only the marketplace and passes even when the plugin itself fails strict validation.For context, this mod needs this functionality because it gives each background agent a row above the prompt: the agent's task, its current step and how long it has been running, kept up after the turn ends, when the session otherwise looks idle (the gap #67485 described). To do that it has to match every agent's loop to the short
descriptionfrom its Agent call, and know when an agent has stopped. The mod is What's Agent Doing:<img width="1493" height="906" alt="Image" src="https://github.com/user-attachments/assets/fa6e63bb-2b8f-4afa-a80e-b2220f62724d" />
Another data point, from a mod that only watches: Flightdeck docks a live agent dashboard beside the transcript, with main-model context and cost, an advisor timeline, every permission verdict, subagent cards and swimlanes, and a session log. Every hook passes its event on unchanged.
<img width="520" alt="Flightdeck: five subagents fan out as cards, switch to swimlanes and finish while the permission gate fills" src="https://raw.githubusercontent.com/scasella/claude-flightdeck/main/docs/media/demo.gif" />
Things I hit on 2.1.287 (macOS terminal; desktop, VS Code and mobile only through
$.ui.mountin the test kit), roughly by how much code they cost:tool.callorturn.step. The built-inadvisorisn't inTurnStepResult.toolUsesand fires notool.call; the only trace is aserver_tool_useblock and anadvisor_tool_result(contentadvisor_redacted_result) insession.appendrows. Could server tool uses show up intoolUses, or get an event with name, id, start and end?tool.checkhas noagentId. Logged at runtime, a background agent'sBashcheck arrives with keys[input, tool, tool_use_id]only, while thetool.callaround it carries theagentId. To tell a subagent's check from the main loop's, I maptool_use_id→agentIdfromtool.call, in module scope, so it's lost on reload. AnagentIdonToolCheckInputwould remove that.ask.ToolCheckResult.rulecovers settings rules andclassic.PermissionDeniedcovers refusals, but in auto mode most checks areaskand I didn't find anything reporting that the classifier (or the person) allowed one. I infer it from the call's result having nodeny. AdecidedByon the outcome, or atool.decidedevent, would make it exact.turn.completehas an emptyanswer(logged:answerLen=0). Its report travels as aSubagentHandbacktool call (tool.callwith the agent'sagentIdand amessage), then as the<agent-message from="…">text opening the next main turn. That works once you know it, but I first parsed the turn text. A line in theturn.completedocs pointing toSubagentHandback, or the answer on thatturn.complete, would save the next person the search.classic.PreToolUsein a hooks module carries only the call's envelope (command, description, tool, tool_use_idforBash): nopermission_modeoragent_id, thoughclassic.UserPromptSubmithaspermission_mode. So a mid-turn shift+tab goes unseen until the next prompt. A mode ontool.check/tool.call, or a$.sessiongetter, would fix it.turn.stepwithagentIds$.agent.list()doesn't name. AkindonAgentLoopwould let a pane label them.$.tool.callmints a freshtool_use_idand raises notool.check, and it drops a passedagentId(documented). So the subagent and permission paths can't be tested end to end; my first tests of them passed falsely. A way to raise a call "as" a loop, with its check, would close this.claude,suggestion,permission,successand others from the binary. AThemeColorunion would make light themes easy to get right.What worked well:
Clientsurface modules with their owneveryframe clock. The animated connectors and live clocks redraw only their own region, which let me drop a 400 ms full-pane redraw. Andclaude plugin validateprinting exactly what a module hooks and calls made the README's footprint section easy to write.@Und3rf10w
Thanks! This was a big blunder on our part - thanks for the report here. This should be fixed as of today's release on v288. Also, nice handle.
@shivam-dhaka
We've changed up the Code element to be robust against bad diffs, also as of v288 - thanks!
Follow-up now that mods have shipped (2.1.288). Two things I measured while writing a small mod that frames the assistant's replies, and one request.
The "summary" rows are not a render site. A
ui.renderhook onAssistantMessagefires for the block that opens a reply (every block my hook logged hadisFirstOfReply: true) and not for the dim narration Claude Code prints between tool calls, the rows tagged· summary. A catch-allui.renderhook logginge.componentover a few turns saw nine sites in a terminal session: ToolResult, UserMessage, ToolUse, ToolGroup, AssistantMessage, TurnDuration, AbovePrompt, PromptHint, SessionMode. No refusal line either, so those rows are drawn outside the render-site system. Repro: any turn where the model writes, calls a tool, then writes again before the final answer. On screen the first block and the final answer sit in my frame and the narration between them does not, which reads as if the mod were flaky. Request: raise those rows asAssistantMessagewith a prop that names them (isInterstitial, orkind: 'summary'), so a hook can style or skip them on purpose, or list the exclusion in the render-sites table.Two observations in passing. A hot reload redraws the whole visible transcript through the new hooks, so rows drawn before the reload take the new look; that one is welcome. And
contexton a rewritteneshipped optional (ToolCallResult.context?), the field I asked to be required in September. I am living with it as a rule in my own mods: every rewrite attaches one.Setup: CLI 2.1.288 in a WSL2 terminal, the mod loaded from the session's dev-mods folder with hot reload.
@poteat
Thank you very much! I can confirm this is resolved in 2.1.288 for all of the cases I tested.
Data points from two mods I shipped on 2.1.288: agentpane (a docked pane of the session's subagents: current tool call, tokens from
turn.stepusage, each one's transcript drawn in place, Stop via TaskStop) and statuspane (a status card in the AbovePrompt band with a progress API other mods can call).What I hit:
SiteView.agentIdis read-only, and no op, command or keybinding action opens an agent's transcript, so agentpane redraws the transcript inside the pane. A$.ui.view({ agentId }), or letting a Button dispatch footer actions such asfooter:openSelected, would let a pane hand off to the native view.Codefalls back to plain, would fail more softly; for now every string has to be budgeted.ctrl+x tab. In terminals that don't passctrl+xchords (iTerm2 here), the only ways to hand a pane the keys are a click orui.open({ focus }). A bindable action that focuses a given pane would help.$(showList($, { unfold = false })next toasync function unfold($)) fails with "the function handed $ "unfold" is declared more than once".ui.open({ columns })is ignored once the person has dragged the dock (as documented), so a mod can't fold to a narrow strip; folding means closing the pane and leaving a tab in the band.Thanks for mods, building against the typed engine has been great.
Making it feel safe to install a mod, for individual users
Thanks for mods. I've published a small one (a usage bar above the prompt, distributed through a third-party marketplace). Building it left me with one concern I'd like to share.
A mod's file access isn't confined.
$.fsis "the file system as the engine's own process reaches it ... An absolute path is used as given", so any mod can read and write whatever the user can, including SSH keys, credentials and other projects. Yet installing one from the Desktop plugin browser is just a click on Add in a catalog. To check, I published a test mod whose button launches a program with$.process.runand writes a file into my home directory. It installed like any other mod, with no warning about either, and both worked. Nothing tells the user what the plugin will be able to do, and the catalog looks like an app store, which suggests a vetting that isn't there.This thread covers how org admins can withhold affordances from
$, which is great. But individual users have nothing comparable, and they are the ones most likely to install a mod they found somewhere. I understand that installing a plugin is treated as consent, but today that consent isn't informed: nothing at install says what was agreed to.My wish is that installing a mod could feel as safe as installing a browser extension. One shape that would work for me:
$.fsis confined to the mod's own plugin directory by default, anything beyond that, including running commands with$.process.run, is declared, and those permissions are shown at install, and again when an update adds more, so the user can decide. Even just knowing that a mod for a usage bar wants to run commands or write outside its own directory would be enough to make most people stop and think.claude plugin validatealready lists the$calls a module makes, so the information seems to be there. But I'd be glad of any approach that gets there.(Claude Code 2.1.286 in the Desktop Code tab, Claude Desktop 2.19675.0, Windows.)
Follow-up from building a brand mask as a mod on 2.1.288. The use case is a client NDA: the model must never see a brand name, while files on disk keep it. I hooked
prompt.submit,tool.call(results, including theisErrorvariant),prompt.attachmentandsession.append, and in my run nothing with the name reached the model. To check the disk side I typed a fictional canary brand and counted where it ended up under~/.claude.~/.claude/history.jsonlkeeps the prompt as typed, so up-arrow shows the real name. I found no event for it in the 2.1.288 types.queue-operationentries beforeprompt.submitrewrites it. I counted 7 occurrences there, while the user rows for the same prompts held the marker.reference.mdsays the opposite: "a plugin that must change a prompt's words everywhere rewrites them at prompt.submit, before they are queued."claude plugin test,$.session.append(...)fails withno implementation for session.append, even with anon('session.append')hook registered beneath it, so I couldn't test asession.appendrewrite in the kit.Could the history entry and the queue record go through the
prompt.submitrewrite, or get an event of their own? If the queue record is meant to stay as typed, one line inreference.mdwould save the next person a measurement.Thank you for shipping mods, and for the detailed answers in this thread.
I re-ran our 9/5 test cases on 2.1.288: 10 cases, 3 runs each, judged by marker files and the debug log rather than by what the model reported. Everything behaves as you described on 9/4:
denynow blocks the call..catchthat returns{ deny }fails closed, 3 of 3 runs.tool.checkdeny holds, 3 of 3 runs.One item is still open from your 9/4 comment ("We should be very clear in the docs about how this works"). The docs don't yet say that a
denyreturned afterawait next(e)doesn't stop the tool. In our runs the file was written 3 of 3 times, while the model was told the write failed. Someone porting a PreToolUse gate could easily write it that way.Suggested text for Guard or change a tool call in
events.md, placed after thegit push --forceexample:And one sentence for the retry paragraph:
A debug-log line when a hook returns
denyafternexthas resolved would also help, because today that path is silent.Thanks again.
Closing the loop on my ask from Sep 4 / Sep 8: make the chain composition importable, so hooks can be unit-tested against the real thing. It shipped in a different shape than I asked for, and the shape is better.
claude plugin testwithclaude-code/testingruns the tests against the engine itself, so there is nothing to import and nothing to replicate.The one behavior I said a stubbed
nextcould not reproduce was the fail-open skip: a hook that throws is skipped and the tool runs anyway. Measured on 2.1.287 with a three-path deny gate (throw / deny / pass through) and a test-sideon('tool.call')standing in for the tool:All three paths pass: pass-through reaches the bottom, deny stops above it, and the throw is skipped with the bottom still running. A negative control that expects the throw to stop the tool fails, and the failure names the skipped hook:
So the fail-open contract is now something a plugin author can pin in CI, offline, in under half a second. I only measured the throw case under the test runner, not the budget overrun or the wedged worker.
@yonatangross thank you for the
claude plugin validatepointer back then. I use it as the static half, andplugin testcovers the runtime half you noted it could not. @durban01 @Spencer-Morley thank you for the follow-up measurements.My ask is retired. Thank you @poteat and team.
Data points from shipping a mod on 2.1.289, desktop Code tab and terminal: WeekToken, a rate-limit pace band above the prompt plus a
/weektokenpane (https://github.com/3dnow/claude-mods).Svgin the band reloads when another site of the same plugin redraws. Pressing any Button in our Pane made the band's progress bar (anisInteractiveSvg) flash. A trace showed the AbovePromptui.renderhook did not run and the source did not change; only the frame flashed, not the Text beside it. Moving the bar to a plain image fixed it:heightonly, markup withwidth="100%", no viewBox and percentage geometry. That stretches with the slot but stops at the image default of about 300px.ui.focus. Noui.pressfollows, so the button needs a second click. We run the button's action ourselves whenui.focuslands on it with no press within about 300 ms, but only if the pane's last-renderede.props.isFocusedwas false, so Tab and arrow walks don't press anything. Inside theui.focushook,$.ui.panes()already reportsisFocused: trueeven for that first click, so it can't tell the two cases apart. A press on the first click, or a flag on the focus event, would make the workaround unnecessary.$.process.runresolvesargv[0]withinit.env.PATH. We rely on it to findclaude, because processes started from the desktop app often get a short PATH (measured:/usr/bin:/binalone can't find it). Worth stating in the docs.http://www.w3.org/2000/svgin code counts as "can send data off the machine", and is combined with a README mention of~/.claude.jsoninto MCP_FORWARDS_CREDENTIAL_ENV.perlcommand line in a disclosure table produced a credential-forwarding hold, and adocumentationUrlpointing at README.md turned the README's screenshots into an UNREAD_ASSET_REFERENCED hold.typesis reported as an unknown field, though the manifest reference documents it andclaude plugin validatefails without it.Thank you for shipping mods! I'd like to follow-up on my Sep 10 comment. PromptSign checks that the skills, plugins and CLAUDE.md files Claude reads are what their author signed. Plugin
0.3.13adds a mod on three events (README,hooks/register.ts). Compared with thePreToolUse/SessionStarthooks we ran before:| | Situation | Hooks only | With the mod |
|---|---|---|---|
| 1 | User types
/skill-name| Not checked at that moment | Checked on every expansion (skill.prompt) || 2 | Subagent starts with a skill preloaded | Not checked | Checked (
skill.prompt) || 3 | Claude calls a skill that fails verification | Call blocked; Claude sees a tool error | The model reads a notice in place of the skill text |
| 4 | Tampered CLAUDE.md or rules file | We warn after the text is in context | We remove the block before the first message (
prompt.context) || 5 | A plugin the user installed fails verification, and it ships a mod | It loads;
SessionStartreports the failure afterwards | We refuse it before it loads (plugin.register) || 6 | A plugin the user installed fails verification, and it has no mod | It loads;
SessionStartreports the failure afterwards | Same report, and its skills reach the model as notices (skill.prompt). Nothing refuses it at load (gap 3) || 7 | Org wants verification enforced | A user can disable the plugin | Admins list it in managed
prependPlugins|Our hooks had no way to handle rows 1, 2 and 5, and for row 4 they could only warn. Thanks for adding
rootandprovenancetoplugin.register; the refusal row depends on them. We keep the hooks forclaude -pand other harnesses.Gaps, smallest first:
skill.promptcarries no path, so we repeat Claude Code's skill lookup to find the directory. Adirfield would let us drop that copy.claudeMdtext,instructionFilesturns undefined and a verifier lower in the chain can't check CLAUDE.md. A read-only list of the files as loaded would fix it.plugin.registerfires for plugins that ship a hooks module. A mod has no way to refuse a skill-only plugin.provenancereadsname@marketplace. With the marketplace commit SHA, a mod could confirm the installed tree is the commit the directory reviewed.On @secondarykey's informed-consent point: the directory review pins an exact commit, so that review could also check a publisher signature and show "signed by" on the listing.
Disclosure: I build PromptSign (Apache-2.0).
Feedback: from building a mod, Human in the loop (Claude assigns the user tasks through registered tools; they wait in a pane until answered):
turn.startcarries noagentId, whileturn.stepandturn.completedo. A mod that tracks whether the main loop is working sees turns begin that it can't attribute (side requests, teammates), and one that later completes under an agent id leaves it thinking Claude is busy. I ended up readingAbovePrompt'sisWorkingthrough a render hook that draws nothing. Couldturn.startcarryagentId, or could$say whether a turn is running?Inputis single-line. On desktop, a multi-line paste arrives with its line breaks turned into spaces. A multi-line field would let an answer like a log stay in the pane, instead of going through the prompt box.For context, this mod needs this because an answer must reach Claude at the right moment (right away while it's idle, saved while it works), and some answers are logs.
Requesting that user-installed mods (function-hook plugins) load in cloud sessions and Projects, and that their UI draws there (desktop Code tab cloud environment, claude.ai/code, project threads).
Today mods work in the local CLI and a local desktop Code tab (Claude Code 2.1.287+, desktop from 2.1.286). In cloud sessions / Projects they do not load, and those surfaces have nowhere to draw panes, bands, or guards already used locally.
Official docs as of 8 Oct 2026: cloud sessions only get hooks if the plugin reaches the session, and drawings do not appear; the desktop plugin browser is unavailable in cloud sessions; plugins installed from the desktop app are not available there; a cloud session does not install plugins declared in the repo’s
.claude/settings.json; user-scoped plugins in~/.claude/settings.jsondo not carry over, nor doenabledPlugins/extraKnownMarketplaces. Projects are mostly cloud sessions, so this covers both.Opened a dedicated feature request: https://github.com/anthropics/claude-code/issues/100737
Related: #88214 (cloud sessions never install plugins from
.claude/settings.json,/pluginunavailable).classic.*hooks in user-installed mods are silently skipped on Team/Enterprise plans and on any machine with managed settings, and nothing tells the user or the mod author.cc-plugin-sec-default@builtinseats itself outermost when the org is Team/Enterprise or the machine has managed settings, and moves everyclassic.*event past the user tier. Only--debugshows it:Seen on 2.1.289 through 2.1.295. Things that make it hard to deal with:
claude plugin validateandclaude plugin testboth pass. The mod loads and part of it never runs. For a notification mod built onclassic.Notificationthat's all of it. For third-party mods it's whatever the author put in classic hooks (e.g. cache-tax seeds its state fromclassic.SessionStartand resets on/clearthere).enabledPlugins+appendPlugins), which most Team users can't do. A plugin installed from a GitHub marketplace can't get there at all yet: managed only seats plugins read from a local directory marketplace listed by relative path ("A plugin delivered from a remote source gains the tier in a later release").Would help:
classic.*events that will be skipped, and a warning fromclaude plugin validate.allowManagedModsOnly), or only seating it when the org actually has something to protect.Three gaps from building a mod that limits agents across sessions (agent-usage-guard, linked below as the use case), as of 2.1.295:
agent.spawndoesn't fire whenSendMessageresumes a finished subagent. Onlyclassic.SubagentStartfires, which can't refuse. A resumed run costs like a new one, so today a mod has to catch it intool.callonSendMessageand work out whether the target is a finished subagent. Couldagent.spawnfire for resumes too, with aresumeflag?/subtaskforks skipagent.spawn.classic.SubagentStartfires withagent_type: "fork", so a mod can count them but not hold them, while model-started forks go throughagent.spawnwithfork: true. Could user-started forks go through the same event?$.store. It's machine-wide, so it's the natural home for counters shared across sessions, but get-then-set races between processes. The workaround is one key per session, summed by readers;update(key, fn)or CAS would remove it.Use case: https://github.com/rbartoli/agent-usage-guard
Following up on my comment above: thank you for
decorationsin 2.1.293. That closed the gap. OpenCues now runs its whole runtime as a mod: the span a note is about is painted in the draft, the note sits under it, and_acts on it before it ever shows in the box. No patch left.Three small asks, all so a mod's paint reads the way it does in a terminal editor:
1. A faint run.
dimColoron a run resolves to the theme's colour for dim text, so with an ANSI theme it comes out asESC[37m, the terminal's white, which is the same as the text around it. A faint option (SGR 2, as Ink'sdimColoremits elsewhere), ordimColormeaning faint in runs, would let a mod mark "this is what_would act on" without picking a fixed grey that depends on the terminal's colour depth.2. The run's colour under the cursor. When a run covers the cursor's cell, the renderer cuts that cell out of the run and keeps only the base style there. So a coloured span shows one character in the plain colour wherever the caret sits. Keeping the run's style under the cursor (with the cursor drawn over it, as a terminal editor does), or an opt-in per run, would keep the span whole while the person edits inside it.
3. A cursor on
$.prompt.fill. A mod that paints between keystrokes (a note that arrives after a pause, an answer that lands) can only do it with a fill, and a fill always leaves the caret at the end of the box. Acursoron$.prompt.fill(where the caret goes in the filled text, as aprompt.editanswer can already say) would let a mod repaint without moving the person's caret.Happy to test any of these on a pre-release.
One more, the ideal behind ask 3: text the box draws but does not contain. Today a mod that wants a note under the draft (as OpenCues shows one,
↳ 💱 1,380 (underscore to apply)under the words it is about) has to put it in the box as text. Everything else follows from that: the mod moves the caret over the note, turns an edit on it into an edit to the draft, strips it before submit, and takes it out of prompts recalled from history, because the history keeps the box as it showed.What OpenCues draws, and what a mod would need to draw it without touching the draft:
↳under the span's first character, at any line of the draft; the draft's lines below move down. Only the engine knows where the draft wraps, how wide a character is, and the box's width and prompt indent, so the mod gives offsets (below: spanEnd,alignTo: spanStart) and the engine places and wraps the text to the box, continuation lines under the text after the connector (a hanging indent).$.prompt.paint({ decorations }), that sets runs and virtual text and leaves the text and the caret alone, would let a mod show it the moment it is ready. Without one, virtual text still needs a fill, and a fill moves the caret (ask 3)._in place while an answer is on its way, which today is a fill every frame.Editors do the same with inline hints and virtual text. With these, a mod paints and never writes the person's draft except to apply what they asked for.
I built a mod on this that I use every day at work, so thought I'd share it as a real-world example.
Lazy Panda Panel opens the Word, Excel, PDF, Markdown and Confluence files Claude makes in a pane inside Claude Code. You click a part, leave a comment, send, and Claude edits the real file. I'm a PM, not a developer, and this is the first time I've been able to review Claude's documents without leaving the terminal.
https://github.com/paragpandyareal/lazy-panda-panel
A few things I ran into, in case they help:
Love mods as I can make what I need in Claude Code. It made something possible that I couldn't have done otherwise.
Data point from a mod I built: rtl-terminal, right-to-left layout for Arabic, Hebrew, Persian and Urdu in the terminal UI (Windows Terminal, conhost, VS Code's integrated terminal). It only works by working around the renderer: wrapping paragraphs itself, right-aligning rows with
Box alignItems="flex-end", pinning runs with RLM/LRM and mirroring brackets beforehand. On other macOS/Linux terminals RLM/LRM are dropped and RLI/PDI become U+FFFD, so the mod steps aside there. Adirprop would remove all of that; details in #99956.https://github.com/MohammedSaud404/rtl-terminal
/plugin install rtl-terminal --marketplace MohammedSaud404/rtl-terminalTwo last ones from running the full OpenCues runtime as a mod:
The module size limit. A module file over 1 MB is not read. Our runtime (its features, reference tables, languages and the decision layer it ships with) bundles to about 900 KB minified, so the next few features take it over the line and the mod stops loading. A higher limit for a plugin's own files, or a word that splitting a mod across several files it imports statically is supported (it seems to work, but we would rather not build on something unpromised), would settle it.
An event when the box changes without a key. History recall, Claude Code's own undo and redo, and the clear after Enter change the draft with no
prompt.edit, so a mod only learns of it at the person's next key (or by polling$.prompt.read). Aprompt.changedevent (or a field on the next edit) carrying the box and what changed it (history,undo,redo,clear, another plugin's fill) would let a mod tell the person going back from the person typing: OpenCues does nothing on an undo, where a fresh draft would be asked about.One for the list, from shipping a mod through an organization marketplace: the claude.ai Sync from GitHub step strips the top-level
typeskey fromplugin.json("unrecognized key ... stripped — the SDK ignores unknown top-level fields"). That key is where the engine finds a mod'sPluginStatecontract, so the synced copy fails the hooks-module validator on every$.statekey and never loads, while the same plugin installed from the CLI or via managedextraKnownMarketplacesis fine. Details, repro and the workaround (module-scope state plus$.ui.invalidate) in #101258.Feature request (2.1.293): let a
turn.stephook name the model that really served the response, for/workflowsand agent rowsContext. Our mod routes some subagents' turns to non-Anthropic models:
turn.stepanswers withoutnext, so the step never reaches the Anthropic API. The stop chunk'sTurnUsagecan carrymodel, but the UI ignores it:/workflowsrow: it showsfallbackModel ?? model.modelis the Claude id resolved at spawn.fallbackModelis taken frommessage.model. For a response a hook made up,message.modelis the engine's pre-hook step model, never the stop'susage.model. Every routed workflow agent therefore reads "Sonnet 5.5" (the spawn model) while a different model serves it.modelis the Claude id resolved at spawn, and nothing a hook returns changes it.agent.spawn), and noui.rendercomponent covers the/workflowsview or an agent row. So a mod has no field to show the real model there.subagentStatusLinereaches onlylocal_agentrows in the terminal footer. It does not reach/workflows, Desktop or Remote Control.Ask (any one of these solves it):
usage.model, copy it intomessage.model. The existingfallbackModellogic would then show the serving model in/workflowsand the agents list with no new API. This feels like the smallest change.turn.stepresult (e.g.display: { model: string }) that the rows render instead of the resolved Claude id.ui.rendercomponent (oragent.spawnfor workflow agents) set the row's model/label text.Why it matters: users of routing mods cannot tell which model is doing the work while it runs. The rows show a Claude model that is not serving the agent. That undermines trust, and it misleads cost reasoning.
(Token counts already work by the documented route: stating usage on the stop chunk. This request is only about the model name.)
Correction to my comment above. On 2.1.293, a mod can make a routed subagent show its model.
$.agent.registeraccepts any non-empty model string. The spawn resolver keeps an unknown id, and both the agents list and/workflowsprint it verbatim, sanitised to[A-Za-z0-9._:/@\[\]-]. So registering the routed agent's definition with a display id such asdeepseek-flash[B]works.What remains, and what the request is about:
message.model. The row keeps showing the spawn id. Honouringusage.modelon a hook-made response (option 1 above) would cover this.turn.step(e.g. WebSearch, the agent-summary fork) going to the API with that id. A display-only field (option 2) would avoid using the model id as a label at all.A data point from a permission mod on 2.1.299, in auto mode. Its
tool.checkhook turns core'saskintoallowwhen its own judge finds the call a reversible step of the user's task, and otherwise returns core's answer.On
SubagentHandback, thatallowcomes back as a denial, and the subagent's report never reaches the parent:That fits the documented intent (the classifier reviews a subagent's final report before the parent reads it), and #98943 reports the same denial from bypassPermissions. What hurts is the outcome: the allow is denied rather than ignored, so the report is lost. For
AskUserQuestionandExitPlanModethe engine already does the gentler thing and documents it: a hook'sallowis ignored and the dialog still shows ("a hook only tightens" in the types; the permissions page, from 2.1.292).SubagentHandbackis not named anywhere I could find.Either of these would let a mod stay generic instead of hardcoding tool names:
allowonSubagentHandbackas the engine treats it onAskUserQuestion: ignore it and run the classifier's review of the report, instead of denying the call.tool.check. Core'saskdoes carryreason: "SubagentHandback requires auto-mode classifier review.", but that is prose. A field next toceilingon the input, or on core's result, saying the verdict is reserved for the classifier or for the person, would let a hook step aside there without matching strings. Close to scasella's item 3 above (who decided anask).Until then the mod keeps a named set of those three tools and returns core's answer for them.
Thank you for
virtualText,prompt.changeand the dim fix in 2.1.299 and 2.1.300. I rebuilt OpenCues on them and tested the new prompt API on a real terminal (Claude Code 2.1.300, WSL2, run inside tmux 3.0a, with a small probe mod that never submits a prompt). What I could confirm, and what is still open:Confirmed working
virtualTextwith a newline draws the text under the line and never in the draft. Over-long text wraps to the box width, but each continuation line starts flush at the text column, so a mod that wants a hanging indent has to pre-wrap its own lines (that is what we do).dimColoron a decoration emits real faint:ESC[2m ESC[37m alpha ESC[0mon an ANSI theme. That meets the faint-run ask from my earlier comment.prompt.changeorigins I observed: Ctrl+C givesclear(empty text); Enter givesclearfirst (empty text), then the submit hook runs, and if the hook drops the prompt the draft comes back asenginewith the caret at the end; the engine's undo givesundo; a plugin's own$.prompt.fillgivespluginwith the plugin's name, so a mod can ignore its own fills.Still open, with evidence
cursoron$.prompt.fill. A fill in the middle of a text puts the caret at the end. With the caret at offset 10 of a three line draft, a fill that replaced the text was followed byprompt.changereportingcursor=30and$.prompt.read()reportingcursor=30, with the text 30 characters long. On a single line this is invisible; on a multi-line draft a mod that lands a value on line 1 sends the caret to the last line.PromptFillArgsin the 2.1.300 types has nocursor.pof a dimalpha, the screen hasESC[2m ESC[37m al ESC[0m, thenpwith no style, thenESC[2m ESC[37m ha ESC[0m. The run is cut out of the caret cell, so a bright "selected" span loses its highlight exactly where the caret is.$.prompt.paintin the 2.1.300 types. Because of (1), the only way to repaint decorations between keys is a fill, which moves the caret, so a mod can repaint without disturbing the caret only while the caret is already at the end of the draft.Probe and capture details are available if useful.
A correction to the module size part of my earlier comment: I measured it on 2.1.300 and it is not the blocker I made it sound like, so please drop that ask.
claude plugin validatereportscannot import "./c.mjs" (from hooks/register.ts): ... is over 1048576 bytes and was not read.session.starthook that imports both files logged their sum).So a bigger mod only has to be built as several files, which is what we will do when we need to. What would still help is for the limit to be written down: I could not find the per-file limit, or that splitting across files is the supported way, in the plugin-authoring reference or the API types of 2.1.300.