Mods - make Claude 10x more extensible

Status Open
Maintainer reply ✓ Yes — poteat
Activity 259 comments · opened Sep 3, 2026
💡 Likely answer: A maintainer (poteat, contributor) responded on this thread — see the highlighted reply below.

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>

Function Hooks Core Architecture.pdf

View original on GitHub ↗

259 Comments

etrippler · 1 month ago

Looks sick. Ship it IMO.

aviseth · 1 month ago

This is sick! Can't wait to try it!

AdityaRon · 1 month ago

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-guard hooks pattern-match raw Bash strings, with known bypasses we document ourselves (obfuscated payloads, git -c/-C prefixes). A tool.call hook receiving e.tool/e.args as structured fields — matched via { tool: "Bash" } — closes a whole bypass class regex can't.

Nesting instead of replacement. I ship no custom autoMode rules because setting a category today replaces the shipped defaults rather than adding to them, risking a silent drop of Claude Code's 65 default soft_deny rules. 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 via before/instead without needing to own a whole ruleset.

§4.2's capability withholding covers real gaps. gh api is allowlisted with no guard today (unlike kubectl), and MCP connectors are invisible to network-guard entirely since it only sees Bash/WebFetch. A plugin controlling what engine.create forwards — withholding or wrapping the network noun — 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, with files/network/model/terminal/permissions as the core primitives — MCP isn't named. Do MCP tool calls route through $.tool.call like any other tool (hookable), or reach the model outside $ entirely? That answer determines whether this closes our biggest known gap or leaves it untouched.

DareDev256 · 1 month ago

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.signal fires when the chain returns or cancels. What cancels a hook whose promise never settles? Is there a per-hook timeout, or does one bad await wedge 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 $.fs reach 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.

wordingone · 1 month ago

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).

andreimaxim · 1 month ago

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!

poteat contributor · 1 month ago

@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.

ahjin2 · 1 month ago

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:

  1. Scoped self-modification. When an agent needs to edit files under .claude/, the options today are approving interactively every single time, or granting a broad Edit(.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.
  2. Guards that survive delegation. A guard written into a brief does not survive into a sub-agent; it has to be re-stated, and re-stating is where it gets dropped. As a hook wrapping every tool call, the guard exists once and applies to everything below it.

Two design questions:

  • What happens when a hook throws? Fail-open (the action proceeds) or fail-closed (the action is blocked)? For anything security-shaped, fail-open is a footgun. I'd want it explicit per hook rather than a silent global default.
  • - How do I test a hook? Shell hooks today are only ever exercised in production — they escape whatever check suite runs before commit. If function hooks arrive without a test harness, the untested-guardrail problem just moves to a new file format.

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?

juanquijandria · 1 month ago

this is good! i can´t wait

DareDev256 · 1 month ago

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. PreToolUse carries 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:

  • 22 read or write outside the workspace (~/.claude/, state dirs, log files)
  • 9 spawn another process (osascript, launchctl, a preview binary)
  • 6 open a local SQLite database
  • 5 read credentials from ~/.secrets/
  • 3 make network calls

§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.spawn re-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 command hooks 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.

andreimaxim · 1 month ago

@poteat apologies for the harsh tone, it's an awesome feature that Claude Code needs to have.

poteat contributor · 1 month ago

@DareDev256 to clarify: $.fs, $.http, and $.process will 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.

Mycosystema · 1 month ago

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!

alhe99 · 1 month ago

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:

  1. A wildcard audit hook is only useful if nothing registered after it can remove or shadow it. If a downstream plugin can unregister it, it can't be relied on as a control — an auditor will ask exactly this. Is an admin-registered hook immutable to downstream code?
  1. Does the wildcard observe denied operations, or only permitted ones? For compliance the blocked attempt is often the more important record. If a restrictive hook returns falsy and the event never reaches *, 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.

yonatangross · 1 month ago

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:

  1. Does $ expose git, or is git "fs.read the .git directory yourself"? Most of our load-bearing hooks read branch, worktree and index state. If the answer is a process.spawn affordance, say which plugin position is allowed to hold it.
  2. How do command hooks and function hooks on the same event interleave? The doc says they run "beside" each other. Does a command hook deny win over a function hook that called next? We cannot migrate 153 hooks in one release, so this ordering is the migration path.
  3. What is the analog of additionalContext? It is our dominant output (62 of 200 handlers inject context before the model reads a tool result or a prompt). The doc names no event that carries it.
  4. What is the unit test harness for ($, e, next)? We have 310 test files that drive the real bundle with real payloads. A documented fake $ and a fixture format would decide whether those tests port or get rewritten.
  5. Is registration order visible and editable by the user in claude plugin list? Under "order is nesting", a plugin installed a week earlier can rewrite or swallow events from one installed later, with no consent surface for a solo user. Related: a * hook above us sees every payload we put on e, including client data; a stated rule for what belongs on e would help plugin authors.
  6. Is the recursion skip logged? Silent skipping is exactly the shape of the dead-hook class above.
  7. Can a hook read CLAUDE_PLUGIN_DATA and the project's .claude/ directory through $? All of our mutable state lives there. And does the engine.create fold re-run on a cwd change? Our per-project capability tiers narrow on /add-dir today, which a frozen $ cannot express.
  8. Will ui.render reach the terminal surface on day one, or Desktop first?

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.

poteat contributor · 1 month ago

@alhe99 This is gold, thank you. The PCI workload is a north star for us.

Is an admin-registered hook immutable to downstream code?

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".

Does the wildcard observe denied operations?

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.

Helban · 1 month ago

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 modifying placement 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 a tool.call hook returns next({...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 deny rule on Read() 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.

yuzushi-dev · 1 month ago

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?

puretensor · 1 month ago

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:

  1. Bash guard is regex over a raw string. -C/-c prefixes, eval, heredocs all get past it. on("tool.call", { tool: "Bash" }) over structured args closes that class.
  2. Hooks that can't fail. Our PostToolUse linters 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? For tool.call, fail-open is worse than no hook. Declare it per hook, not as a global default.
  3. Guards don't survive delegation. Rules in a sub-agent brief get dropped in re-statement. A prepended hook wrapping every tool.call below it is the mechanism. Is $ withholding inherited by sub-agents? The doc should say.

Deciding question, seconding @AdityaRon: our most sensitive guard is a PreToolUse matcher on mcp__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.

poteat contributor · 1 month ago

@yonatangross

Much of your questions are low-level / tactical, I'll try to briefly address some of them. re 1. git is 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 :)

poteat contributor · 1 month ago

@puretensor

Yes, the current plan is for tool.call to hook into both MCP tool calls as well as non-MCP tool calls.

poteat contributor · 1 month ago

@Helban

If a tool.call hook returns next({...e, content}), does the model see what it wrote, or what it asked to write?

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 context attribute on the tool.call return object.

ipoltorak · 1 month ago

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:

/Users/me/scripts/some-autoheal.sh 2>/dev/null || true

|| true appears 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:

  1. A watchdog with an early exit 0 in its healthy branch. Jobs appended after that line silently never ran — for days. The carrier was demonstrably alive, so every check said green.
  2. Two health probes that never executed once. They were installed, correct, and simply never scheduled. Discovered by accident, months later, when an unrelated job failed.
  3. A background job that died in preflight and whose failure alert also failed — the alert text contained a <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 a curl I hand-rolled inside the failing script, with its own untested serialization bug.

What would actually change for me:

  • Typed failure. A hook that throws is distinguishable from a hook that returns. Today both are exit 0 plus stdout I have to parse.
  • Registration is inspectable. on(X, ...) is enumerable at load time. My "is this job actually scheduled?" question becomes answerable by the harness instead of by grep across a shell script — incident 2 stops being possible.
  • Continuation semantics beat || 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 || true is 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.

DolphusCY · 1 month ago

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. -n is not a valid fd flag — it's a find-ism. The call errored on every single run. It was wrapped in 2>/dev/null and a try/catch, which reads like two layers of safety. It wasn't. | head -1 overwrites the pipeline's exit code with head's 0, so execSync never throws, so that catch had 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 was command.slice(0, 60) and my command started with a long cd. The actual match was --apply further 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 to head.

So the parts of this proposal I care about, in order:

  • $ side-effect tracking. If $.fs / $.process calls are recorded, "this hook ran and did nothing" becomes a query instead of an archaeology project. That alone would have caught #1 and #3.
  • A single hook on *. An audit log as one function is exactly right. I currently approximate this with a bespoke observability transport and a dozen JSONL files.
  • Real exit-code semantics. A typed function that throws cannot have its failure eaten by a pipe. The entire class in #1 stops existing.
  • Registration-order nesting. With 116 entries I currently reason about ordering by reading settings.json top 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 $.fs and a plugin's call to it returns a benign empty value instead of throwing, you have rebuilt 2>/dev/null with better types. A revoked affordance should be loud at the call site — throw, or hand back an explicit Denied the 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.

ucsandman · 1 month ago

ship it ship it ship it ship it

Helban · 1 month ago

@poteat

However, you can append your own small system reminder message to prevent Claude from getting confused, mentioning what you changed if anything.

Thanks for the answer. The word I would push on is "can". If a hook rewrites e and returns without a context, 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 modifying hook produces by construction.

So my request is that context be required at the type level whenever a hook forwards a changed e. It costs one field.

One follow-up: does a later fs.read of that file show what is actually on disk? If it does, the divergence ends at the next read, and context is really about the window in between.

cesarvarela · 1 month ago

This is great! Why not have a beta or alpha channel for the cli so you can release stuff like this without worrying?

asim48-ctrl · 1 month ago

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.

  1. Enforcement needs to travel with the repo, and its absence needs to be loud. Our guards sit behind 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 register tool.call deny 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.
  1. Hooks need to wrap sub-agents, not just the parent. Our most repeated failure is a rule in a sub-agent brief ("never --no-verify, never SKIP_LOCAL_CI=1") getting dropped when the brief is restated. Does a tool.call hook registered in the parent wrap calls made by Agent tool children, worktree agents and a spawned claude -w session? If not, an agent.spawn event whose modifying placement injects the parent's stack into the child would do it. Same point as @puretensor's third, from the delegating side.
  1. Give Bash tool.call a parsed argv, not just e.command. Every git rule we have is expressible on argv and none of them reliably on a string: commit only named paths, never git checkout <file> or git reset in 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.
  1. Batch visibility. The model issues independent tool calls in parallel by default. Our rule of never two git mutating commands in one batch exists because two concurrent sessions once force pushed over each other on main. A hook that sees one call at a time cannot enforce a property of the batch. A turn id and the sibling calls on e or next, or a turn.tools event whose modifying placement can reorder or serialise, would cover it.
  1. Cross session coordination. Hooks are per process, the contested state is the working tree. Two sessions on one checkout each think their guard is authoritative. A session id on e and an advisory lock on $, or even a documented $.fs locking pattern, would let one guard hold a branch while the other waits.
  1. A turn end event that can see the diff and amend the outgoing message. Our disclosure flag has to be appended to the final message whenever a listed file was touched. A Stop hook today can block and sees a transcript path. What is needed is the diff since turn start plus the outgoing message, with a modifying placement that appends the disclosure or refuses the turn with a typed reason. That turns a documentation rule into a mechanical one.
  1. Fail closed by declaration, and denials that count. Agree with @puretensor that a mid chain throw in a tool.call guard 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.
  1. context should 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.
  1. The capability model is the right answer to "one session cannot authorise another". Our contract forbids a session writing its own allow list or driving another session's shell, because a prompt injected file could escalate through it. Section 4.2 and admin prepend are the mechanical version of that rule. Please keep the property that a project module can restrict itself but never widen what the user or org granted, the same boundary that closed project level auto mode in 2.1.142.

Six of the nine above are things a shell hook structurally cannot do. We are enforcing them today with prose.

richardkmichael · 1 month ago

@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 engine provides? 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 engine will evolve over time.

poteat contributor · 1 month ago

@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 context in certain circumstances, it would be easy to confuse Claude otherwise. 🙏

medsel06 · 1 month ago

**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.json literally 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: an AGENTS.md saying "the only way to write to the ledger is the invoice MCP tools; no SQL INSERT/UPDATE; never pass onay=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.call hooks 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:

  1. Approval interlock. Deny any call carrying onay=True unless 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.
  2. Period lock. Once a month's e-ledger is filed with the tax authority, no write may carry a date inside that period — no matter which of the 13 servers it comes from. Today that's a check I have to remember to ask for.
  3. Withholding by default (§4.2). This isn't only an enterprise feature. As a single operator I'd take the write capabilities away from my own default session and hand them back per workflow. I want it to be hard for me to shoot myself in the foot on a Tuesday afternoon.

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": {}.

heatherhelen · 1 month ago

Use-case feedback from a workspace that runs a set of shell hooks on most of the current events, with permission rules, a PreToolUse deny guard, and additionalContext injection 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.allow rules match the leading text of a Bash command, so anything in front of the verb (cd … &&, time, VAR=…, a for loop) matches nothing and prompts. We compensate with a PreToolUse hook 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:

  • Can a function hook delegate to a non-JS process? Our existing checks are Python and shell. If the only way to keep them is to rewrite them in TypeScript, the migration cost is the whole existing hook set. A hook that can spawn a child process and consume its result, with the type-safe wrapper in TS, would make adoption incremental.
  • Which of the current hook events map to function hook points? The examples are component rendering and UI interactions. For a headless or CI use, the events that matter are tool calls, prompt submit, session start, compaction, and subagent lifecycle. It would help to know whether "all events" includes those as first-class, typed events.

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._

poteat contributor · 1 month ago

@ipoltorak yes, $.clock exists today in our internal prototype, which gives primitives a la setTimeout etc. if I understand your question correctly. You can also use next.signal to 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 on err?

gbrussich52 · 1 month ago

+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:

  1. A prepended admin plugin hooking plugin.register as an allowlist. Today I do this with a shell gate that scans installs for curl | sh and hashes plugin trees. An in-engine allowlist, plus withholding net at engine.create for plugins I don't trust, replaces both.
  2. 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.
  3. Case study 8, scrubbing secrets before the model reads tool output, paired with the ui.render mask from case study 9 for screen sharing.

One ask: make the side-effect listing from claude plugin validate machine-readable (JSON), so an allowlist plugin can gate on declared effects rather than on plugin names.

deafsquad · 1 month ago

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 PreToolUse is ~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 PreToolUse batch 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 $.process and 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.

"The model sees what it asked to write, for fundamental prompt caching reasons… I believe that's supported in our internal prototype today with a context attribute."

That behaviour is live today, via hookSpecificOutput.updatedInput + additionalContext on PreToolUse. We run a guard that catches pytest … | tail -5 — where the pipeline's exit code is tail'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:

tool_use.input.command : "python -m pytest … | tail -30"        <- unchanged, what the model wrote
transcript text block  : "[rc-mask-guard] appended `; exit \"${PIPESTATUS[0]}\"`
                          — the reported exit code is the runner's, not the filter's."

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: updatedInput is a replacement, not a patch. Returning {"command": …} alone drops every sibling field — it silently discarded timeout: 600000 twice for us and both suites were SIGTERMed at the 2-minute default, with nothing pointing at the hook. The middleware form is spelled next({...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 $.http back 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.

poteat contributor · 1 month ago

@gbrussich52 Indeed, plugin.register is 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.

poteat contributor · 1 month ago

@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.

pablogranate · 1 month ago

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.

deafsquad · 1 month ago

@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.

lyricalpolymath · 1 month ago

+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:

  • foldable answers (conversations become very long and it's easy to loose track and find again parts of the work, folding helps to make sense and scan faster)
  • output highlights or bookmarking (to save relevant parts of the output
  • read it to me (on mobile: add a functionality to read the whole output or a selection),
  • artifact editing ( you briefly had a way to add multiple notes to quote or update)
  • better notifications on wait for me or task complete (I've build one where the local mac voice tells me the name of the project waiting for my input)
  • better / custom session naming and auto-organizing (eg add auto folder loading and grouping, automatic artifact location saving)
  • better skills management (skill grouping and namespacing, as well as allowing skills to be invoked before the session starts in CC Desktop)

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

deafsquad · 1 month ago

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:

session_id      46      tool_input      23      transcript_path  10
tool_name       25      agent_type      12      tool_response     7
matcher "*"     30 registrations

Against the asks in this thread:

| ask | available today? |
|---|---|
| structured e.tool / e.args instead of regex — @AdityaRon | yes — tool_name + tool_input as 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_type is 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 one on('*') |
| 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 (updatedInput first-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.command for 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. $.http to 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.

yourstrulyeden · 1 month ago

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 Notification first. It never fired once. Not slow, not misconfigured, just silent. The working event is PermissionRequest. 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.exe in the 2.1.258 VS Code extension:

{reason:2000, stopReason:2000, systemMessage:4000,
 additionalContext:8000, permissionDecisionReason:2000},   // CHARACTER caps
M6t=200,
{reason:20, stopReason:20, systemMessage:20,
 additionalContext:M6t, permissionDecisionReason:20}        // LINE caps

There is a line cap as well as a character cap, and I had no idea it existed. additionalContext is 200 lines. reason and stopReason are 20 lines each. A Stop hook explaining why it blocked gets twenty lines, and past that the explanation is cut.

My SessionStart loader 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/null with better types.

3. A timeout that reports the fact but never the measurement

One of my PreCompact hooks started dying. The harness said Hook 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 cancelled carries 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:

the hook as it shipped     24.0s
the same hook, one module stubbed out    2.3s
the timeout it was blowing    20s
reading and parsing the whole file    1.4s   <- the file size was never the problem

This is the strongest case I have for $ side effect tracking. If the host recorded that a hook made N $.fs.read calls, 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:

PostToolUseFailure hook timed out (per-hook abort)

and the timeout defaults differ by hook type, e.timeout?e.timeout*1000:30000 for prompt hooks and :60000 for 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:

  • Match on the parsed call, never on prose. 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.
  • Require co-occurrence rather than a single token.
  • Key on measured state where one exists, not on text that describes the state.

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 Stop hook 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. modifying and deny placements are where the feature earns itself, and I would rather have those on day one than ui.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 pythonw specifically 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.

Ruzo · 1 month ago

YES! What I'm hoping to see on $, in priority order:

  1. session.focus / session.blur, carrying e.surface. The Desktop window title is static ("Claude"), so nothing outside Claude Code can tell which conversation is active. One event closes that.
  2. session.title, fired on the auto-summary and on a manual rename (with the source), and e.session.title on every event. Today the only way to get it is scraping the Desktop session-store JSON on disk.
  3. A way to append to the terminal title — if the terminal noun doesn't already expose it, $.terminal.title or a hook on the event that sets it. A session token in the title gives external tools an exact join key in any terminal.
  4. Turn / agent-state events: turn ended, waiting for input, waiting for permission, usage limit hit. Multiplexers screen-scrape these today.
  5. $.schedule (interval or at-time, scoped to the session). Plugins can't run timers, so periodic work ends up in launchd.
  6. e.workspace — cwd, project root, worktree parent, branch — supplied by the engine, VCS-agnostic, since git won't be on $. That removes a git rev-parse shell-out and a class of deleted-worktree misattribution bugs.
  7. A conversation id that survives compaction/resume, plus continuedFrom. One Desktop conversation spanning several transcripts currently looks like several sessions.

And seconding the hooks.json forward-compat point: an unknown key shouldn't drop the whole file.

poteat contributor · 1 month ago

@pablogranate

does an unhandled hook error fail open or closed?

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.

0xfauzi · 1 month ago

Pretty cool! Can't wait to test it out

deafsquad · 1 month ago

@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.

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.

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:

  1. Keep fail-open as the default. It's the right call for the harness.
  2. Require a throw to leave an artifact — the * hook sees it, and it surfaces to the operator. Fail-open loudly costs nothing and removes the whole silent class.
  3. Exempt the capability layer. If a hook that withholds an affordance throws, treat it as a fatal configuration error rather than a skip. Silently restoring a withheld capability is the single failure the model exists to prevent, and it should not be reachable by an unhandled exception three plugins away.

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.

PrimeLineDirekt · 1 month ago

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 additionalContext is and notes the doc names no event that carries it. @ipoltorak asks whether there is a surface for the periodic case, since on('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.submit exists 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.

heatherhelen · 1 month ago

One measurement on the output cap failing silently (@yourstrulyeden's second point): we hit it from the other side in August. When a SessionStart hook's additionalContext went 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._

TheSocialTarget · 1 month ago

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.

poteat contributor · 1 month ago

@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 next returned and making it the return value of the above next).

I am thinking next would only throw (i.e. promise-reject) in three cases:

  1. You passed in something invalid on e, or you tried to do something weird like call next after you had already returned.
  2. You are bottom, or everything below you threw, and you called next
  3. The entire event was cancelled (i.e. the user pressed esc, you took too long, etc.)

What do you think of also running runtime type checks, i.e. forcing next props and return values to have a certain structure at runtime, even if you use any etc (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.

Monte9 · 1 month ago

I built a working prototype of the algebra from the architecture doc: https://github.com/Monte9/claude-function-hooks — npm test runs 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, and PreToolUse already denies, rewrites input via updatedInput, and injects additionalContext. 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 --strict rejects $.perm.allowed(…).value. It says nothing at all about:

if (!$.perm.allowed({ path })) return { deny: "blocked" }   // never taken

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:await prints 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 next prototype preserves the semantics — the signal still fires for a hook that subscribes — for ~6–7× (6.2–7.7× over six runs):

eager AbortController (as specified)   ~5.8-6.6 µs/call
lazy signal via prototype getter       ~0.85-0.93 µs/call

A lazy getter defined per-next does not work: it just trades the controller for a per-dispatch defineProperty. 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!

deafsquad · 1 month ago

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 from max(163, 207) = 207 ms to 370 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 updatedInput have 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 for undefined from a forgotten return next(e), the number-one Koa bug class. So:

  1. Result types as discriminated unions with a required tag, and no empty inhabitant. undefined is always an error. The check is a property read.
  2. Validate on change, not per hook. e is immutable and rewrites are copies, so received === returned means 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.
  3. Failures carry blame — which plugin, which phase. That's Racket's actual contribution to boundary checking: not "invalid", but whose fault. next.origin exists; extend it to a per-dispatch trace.
  4. One next.skipped list. 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 next throws 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 the engine.create fold 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 "deny fs.read outside the repo" is forced into a per-event hook on the hot path. Three things close that:

  • Per-hook failurePolicy: Fail | Ignore and 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.create is 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.
  • A codeless deny form — 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 fails claude plugin validate." That's the entire mechanism behind "no ambient filesystem or network" and "trustworthy by construction." In the same isolate, static validation cannot withhold fetch, require('node:fs'), Bun.spawn, globalThis['fe'+'tch'], or Function(...). Withholding $.http stops 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.read and 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 each next call. 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 :around advice, 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.

deafsquad · 1 month ago

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:

"The result, i.e. the hook's return value, is the method's return value."

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 — updatedInput took 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:

MERGE_COMBINATORS = {
    "permissionDecision":       MERGE_VETO,        # any deny wins; order can't change WHETHER
    "permissionDecisionReason": MERGE_CONCAT,      # every reason survives — was LAST-wins, unchosen
    "additionalContext":        MERGE_CONCAT,
    "systemMessage":            MERGE_CONCAT,
    "updatedInput":             MERGE_FIRST,       # two rewrites of one command can't both hold
    "_consume_events":          MERGE_ACCUMULATE,
    "decision":                 MERGE_VETO,
    "*":                        MERGE_FIRST,
}
MERGE_ORDER_SENSITIVE = {f for f, c in MERGE_COMBINATORS.items()
                         if c in (MERGE_CONCAT, MERGE_FIRST, MERGE_LAST)}

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_SENSITIVE is 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 declarative before/after in 3.0. Drupal 11, rewriting its whole hook system, considered numeric weight and rejected it for OrderBefore/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," floats next(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.

danielmiessler · 1 month ago

Yes please. These look FANTASTIC.

imcammitchell · 1 month ago

I vote yes

Jiao-Joe · 1 month ago

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 UserPromptSubmit hook 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.args for the "read-looking commands that write" class. We lost 41 launchd plists in one afternoon to plutil -extract KEY xml1 <file> without -o - (it rewrites the file in place), and a second batch to sed -i. Today the only defense is a blacklist pasted into every subagent prompt, i.e. "remember to copy the red lines." A tool.call hook 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.

gmoneyn · 1 month ago

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.

gbrussich52 · 1 month ago

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 Bash tool 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 pass 14 ms. So Windows pays ~90 ms per spawn, macOS pays 2–14 ms, and of our ~540 ms CPU per Bash call 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 register instead of once per event. Worth stating in the doc: the migration gain is amortization, not dispatch.

2. claude plugin validate today lists zero hooks. Ran it on hookify, which registers PreToolUse, PostToolUse, Stop, and UserPromptSubmit with no matcher. Output, --json:

{"success": true, "strict": false, "manifest": {"errors": [], "warnings": [{"path": "version", "message": "No version specified..."}]}, "contents": []}

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 a plugin.register gate 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.

  • Declared state. 6 of numbat's 51 rules are 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, since claude -p loops run headless from launchd. @DolphusCY's SQLite hooks, @deafsquad's state daemon and @Jiao-Joe's brain service are the same shape. $.http to 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 $.state with a declared lifetime (event | session | machine) makes that a listed effect and a single switch. Not reach, scope.
  • Model and budget as a hookable call. The doc names model as 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 the Agent tool that rejects a subagent launch without an explicit model, plus a launchd wrapper that checks --model on every claude -p. Neither can see the main session's model or the token count of a request. If $.model.call is hookable with modifying placement and e carries 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.

Gregarious-Games · 1 month ago

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?

XuanLee-HEALER · 1 month ago

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.

Gregarious-Games · 1 month ago

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.

gbrussich52 · 1 month ago

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 modifying hook that turns a Write into a Bash call then
skips a {tool:"Bash"} guard registered below it, and one that turns Bash into Read still runs the Bash guard
with e.tool === "Read" inside. §6.1 defines what a matcher matches, not when. Today's updatedInput has the same
property, 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. deny should be structural, not a convention. In the prototype the engine only knows whether next() was
called, which is Koa's virtue and I understand the pull of it. But a hook can call next(e) and return {deny}: the
action runs and every visible signal says blocked. Falsy denies block but report allowed. next() twice runs the
bottom side effect twice. Suggestion: next is a single-use token the engine tracks per hook, as Koa already does,
and a truthy deny after next was consumed is an engine error. This also answers the earlier question of whether
a deny wins over a next: 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 calling next, misses X→Y→X
cycles across two hooks, and clears when the downstream promise is created rather than when it settles. With
AsyncLocalStorage (or a chain id on next) the guard can cover the hook's whole run and detect cycles, and
next.origin can come from the stack instead of the caller, which in the prototype lets any hook dispatch as core.
A related case from today's hooks: a hook that spawns claude -p gets the same hooks run again in the child, with no
marker 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 === returned means skip) depends on it. The prototype relies on authors
copying, and skipping the copy is a fair speed choice for a sketch. But a lower hook mutating e.command in place
after a guard approved ls ran rm -rf /. One Object.freeze(e) per dispatch boundary makes the sentence in §3.1
true.

5. {} and [], and one shipped-hooks data point. Under §6.1, {} matches everything and [] matches
nothing. Both are correct (empty "all" is true, empty "any" is false) and both are what a computed pattern degrades
to. Worth a validate warning. In the same spirit, today's Bash( as a matcher (an invalid regex) matches nothing
and says nothing. And one correction to a claim upthread that shipped updatedInput is first-wins: on 2.1.260, two
allow hooks that both rewrite the command produce neither rewrite. The original echo hello ran, 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.

alhe99 · 1 month ago

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.

DolphusCY · 1 month ago

@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 catch blocks 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 $.fs and 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 thrown Error I have to sniff a message string to tell the two apart, which is the failure mode this whole thread is about. Make it a distinct kind on the error, not an Error subclass — 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 on err has 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:

interface HookFailure {
  hook: string                                     // 1
  event: string
  kind: 'threw' | 'denied' | 'timedOut' | 'neverRan'   // 3
  effects: CompletedEffect[]                       // 2 — the $ calls that landed
  retryable: boolean                               // 4 — declared by the thrower
  cause: unknown                                   // the original, unflattened
}

To your framing — the reason I'd put effects above cause is that cause tells me what went wrong and effects tells 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 catch and don't rethrow, that swallow should be visible to the * hook. Otherwise function hooks reproduce the empty catch {} that started all of this, just with better types around it.

mikerhodesideas · 1 month ago

yes Ship it please

ogamaniuk · 1 month ago

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?

sidhartha1s · 1 month ago

Strong yes. Solo operator, 16 command hooks across 5 events, 7 CLAUDE_CONFIG_DIR profiles sharing one settings.json by 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.deny list 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/ask rules, (b) the auto-mode classifier, (c) PreToolUse hooks, and the precedence between them is undocumented. Extending @richardkmichael's question: is each a hook on permissions.decide (or tool.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, some gh pr create forms) with no reason surfaced to any hook, so I keep a memory file of what it blocks. A permissions.classify event with e.reason on the return would replace that file.

3. Module resolution must survive plugin auto-update and multiple config dirs

One of my four SessionStart hooks exists only to repair a plugin's hook paths after auto-update rewrites the versioned cache directory (anthropics/claude-code#46915). If hooks.json modules are resolved by path under plugins/cache/<name>/<version>/hooks/, function hooks inherit that breakage on day one. Please resolve modules by plugin id, and say whether register(on) and the registration order live per CLAUDE_CONFIG_DIR profile or per machine. Seconding @yonatangross and @Ruzo: an unknown modules key 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 /proc from 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:

python -c pass                      152 ms
dangerous-command-blocker.py        178 ms
verify-gate.py track                206 ms

Higher than @deafsquad's 86–99 ms. One measurement the thread does not have: with 7 concurrent subagents on this box, a subagent's Read took median 42 s and Bash 68–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 whether e.agent is on tool.call.

5. A shipped precedent for "fail open, loudly", and a question for lifecycle gates

My Stop gate blocks a turn that edited files without a verification run. It fails open after three blocks, but only by emitting a systemMessage that says this is a FAILURE, cite it in the summary, and the one escape is a .verify-skip file 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 (PostToolUse track + Stop gate) 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_active today tells a Stop hook it already blocked once this turn; a chain hook needs the same fact on next or 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._

RajeshPenugonda · 1 month ago

+1, please make the app's own chrome part of the public components set: prompt bar, sidebar, status line not just in-transcript drawings like ToolUse / AskUserQuestion

bachmors · 1 month ago

The 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.

xtrianta-gif · 1 month ago

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 across PreToolUse, PostToolUse and Stop — 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:

  1. takes a backup after every write-capable tool call, but only when the file has content,
  2. notices when a watched file drops under 100 bytes while its last backup was over 1000,
  3. prints the exact restore path.

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 PostToolUse and again on Stop, where it rescans the whole vault. The second registration is pure workaround: script-driven writes never reach PostToolUse, 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 Stop reminder 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 PreToolUse hooks 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 with ensure_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.

discorev · 1 month ago

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.com to 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 support SendMessage resulting in forks.

conceptDawg · 1 month ago

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 UserPromptSubmit stdout, which is blunt and, worse, a mutation of the cached prompt prefix. @yonatangross already asked what event carries the additionalContext analog. 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 a context field) is exactly the right shape. Make it a first-class documented contract rather than an "I believe the prototype supports it," and make context required whenever a hook forwards a changed e. 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. Denied has to be a value the caller is forced to handle, or you've rebuilt 2>/dev/null with fancy paint.

Design questions, ordered by how much the answer changes my migration:

  1. Does $ 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.
  2. What's the test surface? I mutation-test my guards — flip the guard, confirm the test goes red. Shell hooks escape that; they're only ever exercised in production. A documented fake $ 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.
  3. Is there a scheduling primitive, or does 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.
  4. does $ route MCP tool calls, and
  5. do hooks return typed results?

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 $.

IARFLOW · 1 month ago

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 PreToolUse guard. It reads stdin, pulls the command out with jq -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, then printf the JSON around it. Thirty-seven lines, and about eight of them are the actual rules. Getting hookSpecificOutput and permissionDecision right was trial and error, because malformed JSON fails silently. No error, no hook, no clue.

The other three are bad in a different way. SessionStart shells out to osascript to get my Ghostty terminal id and writes it to /tmp/claude-ghostty/$session_id. Then Notification and Stop read 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:

  1. Types and LSP on the payload. I currently learn the shape of tool_input by printing it and squinting.
  2. Shared state between hooks in a session, so /tmp stops being my IPC channel.
  3. Composition order I can reason about. Right now "which hook runs first" is a position in a JSON array.

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.

F3d0s · 1 month ago

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.register matches by name. Is signing or version pinning planned, so the plugin that loads is the code I reviewed?

claude plugin validate is 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 on next.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.

poteat contributor · 1 month ago

@F3d0s Really good questions, thanks

Is signing or version pinning planned, so the plugin that loads is the code I reviewed?

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.

Does the engine refuse to load a hooks-module that fails validation, or can a developer skip it and still run?

The engine does refuse if the author does things like $[<arbitrary expr>], both during plugin registration at startup and during the manual validation tool.

Managed settings stay a file. What stops a local user from editing it, or from running an installation outside the managed configuration?

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" />

Is remote log shipping an intended use case?

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.

42tahara · 1 month ago

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.

frsorrentino · 1 month ago

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 behind CLAUDE_CODE_ENABLE_FUNCTION_HOOKS, and ran it: a plugin with {"modules": ["./probe.ts"]} in hooks.json loads under --plugin-dir, and the chain runs engine.create → session.start → prompt.* → turn.start → turn.step → turn.complete in a claude -p session. So the asks below are against what runs, not against the PDF.

One Linux data point first (Debian/Crostini, 8 cores): python3 -c pass 54 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.complete receives {answer, durationMs, aborted, turnId, reason} and resolves to {text}; no event and no $.model.complete result 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's toolUseResult (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} on turn.complete / turn.step and on the agent completion event, plus result.usage on model.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.spawn exists, and its {model} rewrite is exactly the in-engine delegation gate we run as a PreToolUse script today. There is no agent.complete. Today SubagentStop never fires for agents killed at the five-hour wall, by TaskStop or 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.complete guaranteed for every agent.spawn (finally semantics), with {agentId, parentAgentId, runId, reason: done | aborted | error | rate_limited, usage}. And a sentence confirming that every spawn route (Agent tool, Workflow agent(), fork, background session, teammate) dispatches agent.spawn; Workflow agents already bypass the parent's accounting.

3. effort beside model on agent.spawn. The prototype checks {model} only. Older builds silently ignore an agent's effort frontmatter; we measure that as effort_ignored at SubagentStop. 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 statusLine command receives rate_limits (five-hour and weekly percent, reset time) and context_window; hooks receive neither, so our statusline writes a file the gate reads back. The prototype has session.model/turnCount/messages but no session.rateLimits() or session.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.complete billing. 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 needs e.model and result.usage.

7. $.store scope 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.complete parity with Stop's block. Today Stop returns decision: block with a reason and stop_hook_active marks the re-entry. The prototype maps turn.complete to {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:

  • A throwing hook is already routed around: the debug log says hook failed: … (turn.complete; skipped; its last next() run's result stands), at ERROR level in --debug-file and nowhere else. @deafsquad's "fail-open loudly" is one ui.notice away.
  • $.store.set/get round-trips. $.fs.writeFile rejected {path, content}, {path, data} and a relative path alike with fs.writeFile: takes a path (host check); the shipped binary carries no claude-code declaration 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.
  • A pass-through on("*") settles in 30–50 ms per event on this machine, next() included, and 26 prompt.section dispatches 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.

poteat contributor · 1 month ago

@bachmors

For provenance the chain matters more than the last link.

I'm thinking a next.trace would be appropriate, present after your hooks below have executed (i.e. after you await next(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

Export the event types and the chain composition itself

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 reduceRight with accoutrements. I'll consider what would make sense here though.

@frsorrentino

I disavow knowledge of any such flag.

Guessing an API against a host check is the shell-hook experience again.

If such a flag existed, I would put the typings under a /plugin-types command.

frsorrentino · 1 month ago

@poteat Correction accepted. I had not found /plugin-types; it writes claude-code.d.ts (5,619 lines on 2.1.260) and claude-code-mcp.d.ts, and it answers the fs.writeFile question 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:

  • ModelForkUsage already carries input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens on $.model.fork's result. So the cache-split usage type exists; item 1 is asking for the same record on turn.step / turn.complete and on the agent completion, and item 6 for it on model.complete, which today resolves to a bare string.
  • turn.step carries stopReason with '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.
  • AgentSpawnInput pins tool_use_id, fork, parentModel, permissionMode and lets a hook rewrite model, subagentType, prompt, background, cwd. No effort (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).
  • $.store is a JSON file per plugin under ~/.claude/plugins/store/: machine scope, no compare-and-set (item 7).
  • Nothing on $.session reads rate limits or the context window (item 4).

Items 1 to 8 stand as asked, and item 5 is smaller than I wrote.

tavaresgmg · 1 month ago

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.args on tool.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.

cadoganp20 · 1 month ago

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 PreToolUse guard. That imbalance turns out to be the second point.

1. A PreToolUse Bash 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, so rm -rf $SB/x arrives 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 bare node.

The property that makes it expensive is the direction of the failure. A bare node resolves 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.json rewritten 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.

yourstrulyeden · 1 month ago

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

"it wrote the full text to a file on disk and handed the session the first 2,000 characters as raw, unparsed text"

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:

early Aug     BYe(e,t,r,n=TCu){ if(e.length<=n) return e; /* persist to disk */ }
              TCu = 1e4      <- a 10,000 character limit
              yor = 2000     <- the preview size you get instead

2.1.258       {reason:2000, stopReason:2000, systemMessage:4000,
               additionalContext:8000, permissionDecisionReason:2000}   // CHARACTER
              M6t = 200
              {reason:20, stopReason:20, systemMessage:20,
               additionalContext:M6t, permissionDecisionReason:20}      // LINE
              Agn(e,n,r=M6t) -> slice the lines, truncate to chars, set a flag

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:

  • A returned value that throws on a crossed limit fixes the class, not the instance. @heatherhelen's conclusion and mine are the same. The reason it is worth more than a bigger limit is that the limit is not stable and the shape of exceeding it is not stable either.
  • If limits stay, version them where a hook can read them. A constant a plugin can query at register time is the difference between characterising this once and characterising it every build. Right now the only way to know is to open claude.exe and 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:

py -c pass            (the py launcher)        54 ms
python.exe -c pass    (direct interpreter)     29 ms
pythonw.exe -c pass   (what our hooks use)     30 ms

pythonw -c "import json,os,sys,re"             43 ms
a real PreToolUse guard, real payload, stdin   47 ms

The py launcher 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 as py script.py rather 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. pythonw is 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.

deafsquad · 1 month ago

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 updatedInput is 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: one PreToolUse matcher group ("matcher": "Read") with two command hooks, each printing {"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"file_path": …}}}, the first pointing at b.txt, the second at c.txt, the model asked to read a.txt. The tool read c.txt. Reverse the two hooks in settings.json and it reads b.txt. Last listed wins, and the debug file logs each hook as success with 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_DIR and HOME, env -i, no ANTHROPIC_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.json plus hooks/hooks.json = {"modules":["./probe.js"]}. One plain hook per event per plugin is enforced (a second on("tool.call") throws), so N chained hooks means N plugin dirs. Numbers are the transcript gap from the tool_use block to its tool_result, tool = Read of 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.trace to --debug-file, per dispatch, naming every link:

hooks module obs+chain-01+…+chain-08 tool.call settled in 435.9ms (next() included)

Wall time only, DEBUG only, file only. No value snapshots. There is no trace anywhere in the 4,652-line declaration set embedded in the binary (/plugin-types appends the tool tables on top of that).

2. Route-around. A hook that throws before next(), second of four in the chain:

[ERROR] hook failed: thrower: probe: thrower throws on purpose (tool.call; skipped; what is below it ran in its place)

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.log line (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 Read calls in one assistant message (same message.id), a guard on tool.call that awaits $.fs.exists and then sleeps 3 s. Call #2 entered the guard while #1 was still inside it (inflight: 2), both with next.origin === "engine". The guard's own $.fs.exists dispatched with origin: "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.create step that leaves fs out, or returns {}, constrains plugins only: $.fs suppressed by withhold, inner plugins' $.fs ops rejected, and the model's own Write tool wrote the file both times. $ is the plugins' capability table, not the agent's.

5. Two things I did not expect.

  • The capability model is a source scan at load. $ may only appear as $.noun.event(...). $ && $.fs, passing $ to a helper, or passing the engine.create next(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 a writeFile included, 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), $.fs is 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.

heatherhelen · 1 month ago

@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 SessionStart hook emitting an additionalContext of N characters or N lines and a PreToolUse deny 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 additionalContext arrived whole and parsed; 1,000 lines arrived whole; a 52-line, 4,100-character permissionDecisionReason arrived whole in the tool result. The boundary is 10,000 characters of the additionalContext field 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, then Preview (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.txt path with Output too large (9.8KB) and the same 1,999-character parsed preview; 201 lines arrived whole; a 52-line permissionDecisionReason arrived 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: SessionStart with source: compact; systemMessage, reason and stopReason; 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 SessionStart hook 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._

RemanenetSpy · 1 month ago

When multiple plugins hook into SessionStart or PreToolUse, 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:

export interface HookContext {
  sessionId: string;
  toolName?: string;
  // Isolated, non-overwriting extension slots
  contextSlots: Map<string, string>; // plugin_id -> payload
  disposition: "ALLOW" | "DENY" | "MODIFY";
}

export type NextContinuation = (ctx: HookContext) => Promise<HookContext>;

export class HookPipeline {
  private middlewares: Array<(ctx: HookContext, next: NextContinuation) => Promise<HookContext>> = [];

  use(fn: (ctx: HookContext, next: NextContinuation) => Promise<HookContext>) {
    this.middlewares.push(fn);
  }

  async execute(initialContext: HookContext): Promise<HookContext> {
    let index = -1;
    const dispatch = async (i: number, ctx: HookContext): Promise<HookContext> => {
      if (i <= index) throw new Error("next() called multiple times");
      index = i;
      const fn = this.middlewares[i];
      if (!fn) return ctx;
      return fn(ctx, (nextCtx) => dispatch(i + 1, nextCtx));
    };
    return dispatch(0, initialContext);
  }
}

Plugins contribute assertions to dedicated contextSlots (e.g. memory:l0_anchor, security:guardrail) rather than mutating updatedInput strings directly. The runner concatenates slots at the final dispatch boundary, guaranteeing that memory injection and safety checks compose without collision.

serejke · 1 month ago

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:

  • open-source the Claude Code runtime/orchestration layer
  • keep a smaller, stable plugin API for normal extensions
  • keep Anthropic-specific services, auth, billing, model APIs, etc. _closed_
  • let people fork the runtime when they actually want to change fundamental execution semantics
Level 1: config
CLAUDE.md / settings

Level 2: skills
prompt-level reusable behavior

Level 3: plugins
supported stable extension API

Level 4: runtime fork
"I actually want to change scheduling/context/tool semantics"

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.

kelleysd-apps · 1 month ago

Ship it! ASAP need this to shore up my personal dev harness.

gbrussich52 · 1 month ago

@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 PreToolUse group, "matcher": "Bash", two command hooks, each returning permissionDecision: allow plus updatedInput, 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 permissionDecision it is harmless: deny wins whatever order the answers arrive in. updatedInput has 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 as success.

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 ask with 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:

  • @yourstrulyeden You are right. I turned one Windows box into "Windows pays ~90 ms". Your launcher numbers show the spread is structural. Struck.
  • The prototype behind CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is 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.

gbrussich52 · 1 month ago

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-dir per hook). Evidence is a marker file or a --debug-file line 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 e each link receives, so a rewrite above changes what matches below. e is frozen; assigning to it throws and the hook is skipped. [] as a matcher refuses the module at load with the matcher must be a plain object (a partial of e). A hook that calls $.tool.call before its own next is skipped for the nested dispatch, the line deafsquad quoted. next.origin is 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 on tool.call for Write, 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; for deny after next, 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 deny as typeof deny === "string" && deny.length > 0 at the check that already rejects non-strings, and refuse "" the same way it refuses 0; make a deny returned after next was called an engine error, since the action is not undoable; make the second next() in one hook throw, the way the third one already does. All three are one condition each in the tool.call result 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.

kapitecsoluciones · 1 month ago

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 stop import fs or child_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.

ahjin2 · 1 month ago

@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.allow rules on .claude/** are no-ops; my own settings file documents it as a built-in protected path.
  • - A benign Edit under .claude/ went through with or without a PreToolUse hook returning permissionDecision: allow. The hook made no observable difference.
  • - - A sensitive Write in the same directory (creating a hook script) was refused by the classifier — no prompt, no hook decision involved.

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.

kmbopal · 1 month ago

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:

  1. A hook that can start a turn or trigger /compact on its own session. Today nothing inside a session can do either, so we run an external process that literally types /compact and 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.
  1. A hook that can veto a tool call with a reason the model sees. Several of our rules are enforced only by instruction (state a proof clause before a gated operation, never 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.

deafsquad · 1 month ago

@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 PermissionDenied hook's log, not the model's report.

| action | outcome |
|---|---|
| Write tool creating a new, comment-only hook script under .claude/hooks/ | allowed, no prompt |
| shell redirect creating the same kind of file | allowed |
| rm -rf of 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 /a with 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 classifier rather than a written explanation". .claude is on the protected list, and the same page says permissions.allow rules "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's allow never 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 allow can carve an exception for a protected-path write. It says so for rm on critical paths ("no allow rule or PreToolUse hook allow approves"), 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 PermissionDenied with this input on the wire:

{ tool_name, tool_input, tool_use_id, reason: "Blocked by classifier" }

A hook may answer { "retry": true }; the reference says retry is ignored "when the classifier produced no verdict". That phrase is prose, not a field: the 2.1.260 binary contains no classifier_verdict and no classifierVerdict, 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 classifier for 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 retry is the other half of this event. My PermissionDenied hook 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 and claude-code, nothing else". 87 globals in total, timers, crypto, structuredClone, eval and Function present 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.call returning { 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 a stopReason on turn.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.

BGPlayz1 · 1 month ago

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/**/*.jsonl to fill in what the hooks don't say (subagent lifecycle, end_turn vs. 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:

  1. A single * 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.
  1. Cross-surface parity. The demos show the same ui.press hook 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.
  1. Focus / lifecycle UI events. Things like "this session's window/tab was focused", "this session was resumed", "this session's window was closed" would be very useful. Today 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.
  1. Outbound side-effects on $. 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.
  1. Ordering and performance. For a * 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/next model 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.

poteat contributor · 1 month ago

@kapitecsoluciones

Is the isolation a boundary or a convention?

It's a boundary! The current design is a Bun Worker surrounding the entire plugin realm, with each plugin then having a node:vm wrapper - 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 cannot import fs etc. The boundary's job is to make sure everything goes through $.

Wrapping the render touches human approval.

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

next(e) called twice

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.

{deny: ""}

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 onto tool.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 onto Read, 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 merely const 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.

estelamolinero-png · 1 month ago

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 call next(e)", with const approved = result.deny === undefined. @gbrussich52's case was a hook that calls next and 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 calling next and without a deny field yields approved === true for 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 deliberate instead and a forgotten return 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 only throw in the architecture doc is ⊥, §6.2). @deafsquad named the forgotten return next(e) as the number-one Koa bug class; the single-use next token @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 a deny would separate the two.

The guarantee I'd ask for comes from having run a fail-closed governance layer. On 23 August the agt-governance plugin (from microsoft/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 with AGT policy evaluation failed closed: Audit log ... failed hash-chain verification. Not only Bash — echo test denied, WebFetch denied, Read denied, and the plugin's own agt-status skill, 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 personal settings.json command 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.

jdainsworthsnb · 1 month ago

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:

  • Built a throwaway diagnostic plugin (claude plugin validate clean), hooked agent.spawn/tool.call/turn.start/turn.step, logged every field to a file.
  • Control-tested each negative before trusting it (an ordinary interactive Agent-tool dispatch does fire agent.spawn and 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.spawn never 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 (with turnId) never fire for it either — checked because turn.step's toolUses looked like a promising way to group calls, but there's nothing to group.
  • tool.call does fire, with the real command — this is the only surface that sees it at all. It carries tool/tool_use_id/the tool's own args and nothing else — tool_use_id is 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.call hook 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.call and as its own accessor ($.dispatch.current(), mirroring $.session.id()) so a hook can read it regardless of which event fired. Something like:

type DispatchContext = {
  id: string;                 // this dispatch's own stable identifier, same across every event it produces
  parentId: string | null;    // whatever dispatched it; null at the top-level interactive session
  type: string;                // the agent/subagent type it's running as (mirrors AgentInfo.type today)
  label?: string;               // caller-supplied label/description for this dispatch
  workflow?: {                 // present only when dispatched from a multi-agent script/pipeline tool
    name: string;               // the workflow/pipeline's own name
    runId: string;              // this run's id
    phase?: string;             // current phase/stage label, if the runner has stages
    task?: string;               // an item/task identifier, for a runner that fans out per-item
  };
};

The workflow sub-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) thread parentId so 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.call by 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?

kmbopal · 1 month ago

@deafsquad thanks for testing that - it answers two of my three. A tool.call hook that skips next(e) and returns a deny reason is the veto I was after, and $.prompt.submit gives 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:

  1. A way for a hook to compact its own session, something like $.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. Today compact only shows up as a stopReason on turn.step, so a hook can see one happened but can't cause one.
  1. A way to read the context window numbers from $ (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,

sidney-hamada · 1 month ago

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.render with { 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.call hook 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 PreToolUse guard 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.js over 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 single Write/Edit fires 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.args instead 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.

Alexragomes · 1 month ago

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.

  • 131 enumerated controls. 125 are triggered only from inside a protocol the agent runs. 81 are scripted assertions. Nothing reads their log except the script itself and a freshness check that only looks at the timestamp. Nothing blocks on red anywhere.
  • A SessionStart command hook emits ~83k characters; the agent receives ~2.2k (97.6% discarded) in 428 of 672 sessions. My "sentinel" for this is a protocol step the agent chooses to run. Observed detection rate: zero.
  • 42 protocol steps per session. 28 leave no trace, so I cannot tell which ran.
  • A written note claimed 22 commits were unbacked. The automated check covering that same fact reported all-clear. They disagreed for six weeks in plain sight. Nothing anywhere compares a written claim with the check that covers it.
  • My commit-authorship guard is a git hook, so it is path-independent and fires on raw shell git. What skips it is --no-verify — a flag inside the command text that no tool-path hook can see.
  • 0 of 131 controls check domain output. All police the machinery.

What I'd use from the current declarations, in order: (1) tool.call on Bash with the command visible — not a convenience; for the --no-verify case it is the only position from which the escape is visible at all; (2) session.start with the payload size inspectable, so an 83k injection can be blocked or truncated deliberately; (3) $.store for "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.

cwschroeder · 1 month ago

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 Stop hook 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-dir probe plugin, -p runs with --debug-file:

  1. 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.
  2. prompt.context appending 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 -p run in every log, with two process.run invocations, so anything that touches a relay pays twice.
  3. session.start fires and can run the helper's drift check.
  4. Without the env flag the module is skipped silently: hooks modules not loaded: rollout flag (tengu_plugin_hooks_modules) is off. The plugin loads fine and nobody notices. settings.json env does enable it.
  5. A hook that throws is skipped: 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.complete resolves to { text } shown beneath the answer; it cannot hold the turn. Today only a command Stop hook with decision: "block" can. A { drop: reason } on turn.complete, in the shape prompt.submit already 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 on register'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 Windows python3 Store placeholder trap that the in-process module otherwise removes. A platform field on session.start's input, or $.session.platform(), would let a module pick python or python3 without probing.

Yes from me otherwise. The Windows portability alone would remove the reason we have no hooks at all.

koko1000ban · 1 month ago

Two data points on the error-model question, from a solo install of 31 command hooks (15 registrations on PreToolUse, 11 on Stop). 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 invoked python3 hook.py and 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. Stop inverts 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 on Stop, 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 Stop can 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 with onError as 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.call and on Stop those are opposite directions.

sramoin-beep · 1 month ago

+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_input strings on stdin and exit 2 to 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:

  • False positives from string-parsing Bash. A guard that must decide whether a command writes to production has to regex the command text. It has blocked grep, git log -S and 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".
  • No composition or ordering. Three PreToolUse hooks on Bash each re-parse the same command independently. There is no next(), no declared order, no way for an admin/global hook to wrap project hooks so that a project cannot loosen it.
  • Refusal is a side channel. We print JSON to stdout and exit 2. A typed refuse({reason, guidance}) that the model sees as structured context would replace a lot of fragile plumbing.
  • Side-effect scoping is exactly what we need. Our "patrimony guard" blocks writes to the production brain unless a 30-minute GO token file exists. In the proposed model that is one function narrowing $.network/$.fs.write for the session — and the * audit hook (video 7) is precisely the audit log we maintain by hand today.
  • Stop gates need state. We block session end when tsc/biome/vitest are red and TS files changed — recomputed from git status inside a script. A typed stop hook with access to the session's diff would be cleaner.

What we'd ask for:

  1. Typed intent for Bash (parsed argv, cwd, env delta) and for MCP tool calls (server, tool, validated args) — not just the raw string.
  2. Registration-order nesting with an admin/global prepend that project-level hooks cannot override.
  3. Structured refusal with a reason + guidance surfaced to the model.
  4. A migration path: keep command hooks working alongside function hooks, so we can port one guard at a time.

If this ships, we will migrate all seven and report back. Happy to share our guard sources as a test case.

frsorrentino · 1 month ago

@koko1000ban Your Stop case is the one I was circling with item 8. fable-director's Stop hook is the blocking kind: it refuses the end of a turn once actual spend passes 3× the declared budget, and it relies on stop_hook_active to 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 refused turn.complete has 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-file already logs hook failed: … skipped, so a ui.notice on that path would make fail-open loud without waiting for the new error model.

OmerKal93 · 1 month ago

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.

yourstrulyeden · 1 month ago

@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 SessionStart emitting an additionalContext of 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.

sent                                     model's last visible line
    409 chars,  10 lines   (control)     L0010   whole
  8,240 chars, 201 lines                 L0201   whole
  8,199 chars, 100 lines                 L0100   whole
 10,099 chars, 100 lines                 L0019   cut

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.

  1. A hook block with no matcher key 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.
  1. Both failures look identical to a cap truncating the payload to nothing. I was one step from posting that as a finding.

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.

cvuijst · 1 month ago

I explored the Function Hooks API in the current binary (v2.1.261) behind CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and 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.start hook that polls a directory with $.clock.every, reads dropped .txt files 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.

TweedBeetle · 1 month ago

+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 Link element's https: or http://localhost rule fits a local cockpit well. A render hook on ToolUse or AbovePrompt could 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.id in 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 AbovePrompt hook drawing two Links and a Button renders fine in Ghostty, the digit hotkey presses the button, and ui.press settled 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, with next() included, which is fine for a status row but worth knowing if anyone plans to redraw on a timer.

poteat contributor · 1 month ago

@sidney-hamada

can a hook originate a question?

Yes, there ought be a e.g. $.ui.ask.

a hook that declares itself fail-closed and throws should deny.

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:

  1. You throw
  2. You took too long
  3. You returned the wrong data shape, or passed in the wrong data shape to next

All three of these are within your control to prevent and detect

@TweedBeetle

The render hook itself took 1.4 s on the first draw

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

dcbenji · 1 month ago

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:

  1. Table-driven deny/rewrite on tool.call for a handful of commands with real history. A bare shopify app deploy that silently deletes shop-level records a rollback can't restore, git commits from the wrong machine into a Syncthing-shared repo, a docker logs variant that hangs the prod daemon. Case study 2, basically.
  2. Secrets redaction. My API tokens live in .env, and every cat .env or curl -H puts them in the transcript. Case studies 8 and 9 are exactly this; the store-real-value-show-placeholder pattern is the piece shell hooks can't do.
  3. A per-session store to catch things a stateless per-call hook structurally can't: N anonymous GETs against my storefront (the hazard is the loop, not any one call), and a PUT to a theme asset with no GET earlier in the session (silent clobber).

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!

koko1000ban · 1 month ago

@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 with exit 2 logs the same line as one that could not start:

[DEBUG] "Hook PreToolUse:Bash (PreToolUse) error:\nblocked by probe guard"
[DEBUG] Hook denied tool use for Bash

Only that second line separates them. An exit 0 hook whose JSON fails schema validation lands on error: 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 timeout logs nothing at all — no error:, no cancelled, no line naming it. Isolated config, one registered hook, two runs each, with the control in the same batch: the identical 20-second hook logs success: when no timeout is 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: command hooks, headless. The launch-failure case also reproduces on 2.1.258 / 2.1.238 and on Linux.

gbrussich52 · 1 month ago

@poteat Thank you, that settles both of mine. And {deny: ""} is no longer a red herring on 2.1.261: a tool.call hook on Write returning {deny: ""} without next now logs resolved 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. deny after next and next() 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, one tool.call hook matched on {tool: "Write"}, headless Haiku asked to write one file, the file on disk as the witness, --debug-file for 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 next does 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 deliberate instead and the forgotten return are not identical values to the engine, because a deliberate instead has 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 calling next is not a deny; only {deny: "reason"} is. That sentence in the docs, plus the ERROR line surfaced where a person looks (@deafsquad's ui.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 on tool.call it lands on the permissive side by construction.

Your question (a), measured with a personal settings.json PreToolUse and PostToolUse command hook on Write beside the function hook. Timestamps from the debug file:

00:40:24.680  function hook entered (its $.ui.log)
00:40:24.709  Hook PreToolUse:Write success
00:40:24.714  tool_dispatch_start Write
00:40:24.730  Hook PostToolUse:Write success
00:40:24.761  function hook settled (next() included)

Command hooks run inside next(e), beneath every function hook. When the function hook denied, neither command hook ran: no marker file, no PreToolUse:Write line. So a solo settings.json does 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 reads hooks/hooks.json from 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 with Bash, 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.

frsorrentino · 1 month ago

@koko1000ban Measured beats guessed, thanks. I retract the --debug-file shortcut: 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 explicit hook.error event in the engine, separate from deny (a deny is the hook working), raised for launch failures, schema rejections and the registered timeout alike, with the hook name and the outcome the engine applied. That is what allow-and-report needs to report to; without it the third value has nothing to say.

rele-code · 1 month ago
  1. A push guard that is code, not a rule. Twice this month a PR from a worktree that had drifted behind origin/main deleted ~3,000 lines of other people's code; CI passed. Today we wrote a bash PreToolUse hook that string-matches the command for git push and counts ^- lines in git diff origin/main. It immediately produced a false positive: it blocked gh issue comment --body-file because the comment text mentioned git push. With function hooks this becomes a typed intercept on the actual git invocation, plus next() so it composes with our other guards, instead of regex over a shell string.
  1. Redacting secrets before the model reads tool output (your case study 8). Our rule is "secrets on prod are typed by the human, never by Claude". A hook that rewrites .env/printenv output to *** before it reaches the model is the mechanism that rule needs.
  1. One * 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.

autorundev · 1 month ago

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. PostToolUse fires after the result is recorded and cannot alter it, so masking has to be done by rewriting the command in PreToolUse.updatedInput: { CMD ; } 2>&1 | masker; exit ${PIPESTATUS[0]}. That merges stderr into stdout and needs PIPESTATUS to keep the exit status honest. It also matches on command text, so a Python heredoc that merely mentioned .env got wrapped. The after placement removes all of that. On 2.1.263 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 I 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. validate checks 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:

❯ ./probe.ts hooks: tool.call
❯ ./probe.ts calls: $.fs.read
✔ Validation passed with warnings

There is no $.fs.read in the runtime $.

2. A throwing hook fails open. Same plugin, hook not wrapped in try/catch:

[ERROR] hook failed: <plugin>: $.fs.read is not a function
        (tool.call; skipped; what is below it ran in its place)

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.is and 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:

  • Can a hook read files at all? $ 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 .env files, 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)?
  • Ordering vs. authority for security plugins. §2.3 says earlier registration grants more authority because it wraps more. For a redactor the interesting position is the opposite one, closest to the bottom, so it sees what the core actually returned before anything above can re-expose it. A worked example in the doc would help here, since the natural instinct ("put security first") gives the weaker placement.

Reproduction for everything above: 2.1.263, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, a skills-dir plugin whose hooks/hooks.json is {"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.

fragmentsstudio · 1 month ago

Two measured cases from a repo that leans on hooks heavily, and a question the proposal leaves open.

1. A PostToolUse hook on Agent cannot 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 after next(e) would let the stamp mean what it was built to mean.

2. A PreToolUse hook 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.call hook 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.

poteat contributor · 1 month ago

@autorundev This is gold, thank you.

validate checks the spelling of $, not the existence of the noun

Indeed, validate only syntactically checks your plugin; because engine.create exists, 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 upstream engine.create (we can also syntactically enumerate everything a plugin adds to $). I think this would solve your problem in the general way.

Can a hook read files at all?

Yes, we plan for a $.fs to 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 (via Read settings), etc.

1. Admin: Limit all plugin file reads to cwd OR /tmp
on("fs.readFile", ($, e, next) =>
  inCwd(e.path) || e.path.startsWith("/tmp") ? next(e) : { deny: "outside the project" }
)

(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
on("fs.readFile", async ($, e, next) => {
  const { decision } = await $.tool.check({ tool: "Read", input: { file_path: e.path } });
  return decision === "allow" ? next(e) : { deny: "Read rules refuse it" }
})
(paraphrased) How can an org-managed plugin provably redact a tool result arising from core, from higher plugins?

Indeed, an org admin can append their own plugin as well. So I would expect this:

on("tool.call", async ($, e, next) => 
  recursiveStrReplace(await next(e), /my_super_secret_proprietary_data/g, "[REDACTED_BY_SECURITY_PLUGIN]")
)

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 call next, but none of this puts back the data you removed - sans the following paragraph)

One aspect is that next.trace, among other things, enumerates e snapshots across the chain below you - useful for understanding the chain, timing, latency metrics, etc. ergo I'm thinking next.trace is deep and scary enough (but useful!) that it ought be logged in the validation step and present as usage-metadata on plugin.register, so you may (programmatically or otherwise) decline to register a non-managed plugin which reads from it, in the first place.

{ onError: "deny" }

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.trace to have 100% confidence that all of your desired hooks did run, and if that entry isn't present on the trace, kill the session.

poteat contributor · 1 month ago

@fragmentsstudio

does a tool.call hook see tool calls made inside a subagent?

Yes, it does today in our internal prototype.

ernestoromero2-cmyk · 1 month ago

+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):

  • They're advisory/reactive. Our safety rules ("never perform a destructive action on the external system without explicit confirmation", "mask secrets before they're read", "don't claim success until persistence is verified") currently live as prose the model is asked to follow. That's exactly the failure mode governance is supposed to remove — a rule enforced by remembering it is not enforced.
  • A shell hook can't cleanly rewrite/redact a tool result before the model reads it, nor deny an in-flight action deterministically and hand back a controlled result. The middleware model in this proposal does both.

What we'd adopt it for, day one:

  1. Deterministic destructive-action gate — a policy hook that blocks (or forces human confirmation on) any tool call matching an irreversible external operation, enforced by the plugin, not by the model.
  2. Redaction before read — mask PII/secret fields in tool outputs down to the minimum scope a task needs. The demo where a secret is masked before the model reads it is exactly our #1 SGSI need.
  3. Definition-of-Done gate — a post-action hook that refuses to let a "success" status through until persistence is independently verified.
  4. Org-level policy that wraps everything — the ordering-is-authority / admin-prepend design is precisely what a multi-operator team needs to enforce shared discipline centrally.

Design feedback that would make or break adoption for us:

  • Language boundary is the biggest risk for non-TypeScript shops. Our stack is Python; a TS-only policy layer means rewriting governance logic we already have and trust in another language. Please consider either (a) a language-agnostic policy boundary (subprocess/WASM) for the deny/rewrite capability, or (b) exposing that same deny + rewrite-result power to the existing JSON hook protocol. The UI-drawing power can stay TS-only; the governance gate should not require TS. (This is the crux of #45427 for us.)
  • Fail-closed by default. If a policy hook errors, the action must be denied, not passed. A governance gate that fails open is worse than none.
  • Structured, persistable audit record on every deny/rewrite (who/what/why/when). We need the trail for our quality system, not just a log line.

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.

muloka · 1 month ago

I maintain a small set of plugins for Jujutsu (jj) repositories. Every hook runs jj as 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 -p on Haiku, one --plugin-dir per 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.repo is 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 add directory 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 null even when the main repository is git-backed. Any plugin that reads null as "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", …), awaits next(e), and answers from jj root when 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 logged session.repo nested in jjprobe-b with the provider first and with the consumer first. An extra vcs field 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.repo is 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 defines root as "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.repo as the extension point for version control other than git. Reserve vcs?: string so a provider's answer type-checks. Add workingCopy?: string beside root. That last field is for git users too: a worktree session gets the main tree as root and 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 with on("audit.record", …) that appends to $.store, and records its own start from its session.start hook. 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 logged audit.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.create returns { ...below, store: {…} } does not load. The engine logs engine.create replaced $.store, added by core; to change what a method does, hook the method and 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() {…} } })) logs engine.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 logged jj.ctx nested in jjprobe-b.

Plugin A calls the same $.jj.ctx() from its own session.start hook and receives the bottom method's static return. The engine logged jj.ctx skipped: re-entry (its own frame is being dispatched; origin jjprobe-a). The same skip hits A's own session.repo call in finding 1. The consumer sees a root. The provider still sees null.

The bottom method receives only the argument. I measured argc: 1 and 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 with next = ⊥. 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-types writes 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 no jj. 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 against unknown, 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-types fold 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.context fires once or twice per -p turn here. Its e is {blocks: [{name, text}, …]} with claudeMd as the first block. A block appended there replaces SessionStart additionalContext injection 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.run takes an argv array and runs no shell. jj … --ignore-working-copy ran clean in every case, with no quoting. That removes the whole class of shell-string bugs the thread has catalogued for command hooks. Please keep process in 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.json travel with a clone and need no install. @gbrussich52's measurement puts those hooks innermost. A modules key 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.

deafsquad · 1 month ago

@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, -p runs, --debug-file, one --plugin-dir probe 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.complete hook that calls $.prompt.submit({ text: "/compact" }) is refused by a host check, verbatim from the debug file:

$.prompt.submit (not awaited): prompt.submit: submits a prompt to the model;
a text beginning with / would run a command as the user ($.tool.call runs tools) (host check)

So a plugin cannot reach /compact through the prompt door, by design, and nothing else on $ compacts ($.turn.abort ends a turn; turn.step reports stopReason: "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.fork runs 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, its cache_read_input_tokens is 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." }) from turn.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 fork returns null on 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.call hook that awaits $.clock.sleep(120000, { signal: next.signal }):

[ERROR] hook failed: slow: exceeded 10000ms budget (tool.call; skipped; what is below it ran in its place)

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 through next.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.

poteat contributor · 1 month ago

@ernestoromero2-cmyk

Our stack is Python; a TS-only policy layer means rewriting governance logic we already have and trust in another language.

Would a $.process.run be 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.run is 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.fetch so you could spin up a daemon in Python and have your function hook cheaply call out over the local network, for example.

gbrussich52 · 1 month ago

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_start lines in --debug-file.

Correction. Last night I wrote that Haiku wrote the file with Bash after Write was refused, twice in four runs on 2.1.261. On 2.1.263, 30 runs, it did not happen once:

| tool.call on Write returns, no next | 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) then return {deny: "late"}: the tool dispatches, the file is on disk, the engine logs resolved 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 a deny after next an engine error.
  • await next(e); return await next(e): tool_dispatch_start tool=Write fires twice with the same toolUseId, 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 second next re-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": 1000 key beside "modules" in hooks.json loads without a word and does nothing. An unknown key in hooks.json being accepted silently is the same shape as the settings-hook matcher typo yourstrulyeden hit; a warning at load would cover both.

Rig: 2.1.263, macOS, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, --permission-mode acceptEdits, Haiku.

tusharsrivastava · 1 month ago

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!!! 🎉

gmantova · 1 month ago

What I'd ship: a latex-style-check hook.

Input — the LaTeX of an Edit/Write on *.tex, plus the \newcommand list in my project's preamble.sty.
Output — one line folded into the tool result: used my macros, or drifted: 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.

gmantova · 1 month ago

Concretely, the plugin: latex-style-check.

Event — one hook on tool.call, matching Edit/Write on *.tex.
Input — the LaTeX being written, plus the \newcommand list read from the project's preamble.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 tabular when my preamble defines a wrapper for it; \textbf used 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 \newcommand list, 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.read reaches 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.

DenDenov · 1 month ago

So you're making a calculator that will calculate 2x2=4, but will do it faster, be more powerful, and consume more kilowatts? Commendable.

techmik · 1 month ago

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.json here 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 of settings.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.

DenDenov · 1 month ago
Я использую Claude Code с большим количеством хуков — около восьми хуков для скриптов оболочки на трёх машинах под управлением Windows. Два момента, которые пока здесь не обсуждались, вызывают вопросы: Регистрация хуков не может контролироваться версиями. settings.json Здесь она осуществляется для каждой машины отдельно (список разрешенных файлов Bash соответствует буквальным путям), поэтому каждый хук вручную добавляется в конфигурацию каждой машины и копируется вручную при изменении, и ничто не указывает на устаревание конфигурации машины, кроме визуального сравнения файлов. Если бы регистрация хуков осуществлялась в виде плагина, а не через репозиторий settings.json, она бы перемещалась вместе с репозиторием и создавала бы реальное сравнение. В Windows «shell hook» означает Git Bash, который искажает аргументы до запуска скрипта. Пути Windows, $varsсимволы подстановки и флаги с одной косой чертой обрабатываются слоем MSYS до того, как скрипт их увидит — я веду список конкретных ошибок. Функция TypeScript, получающая типизированный $объект, полностью обходит оболочку, что в Windows является исправлением не только эргономики, но и повышением корректности. В целом +1. $Модель объекта побочного эффекта и модель вложенности промежуточного программного обеспечения четко соответствуют реальным проблемам в данном случае.

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"

Spencer-Morley · 1 month ago

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 mentions curl in a comment gets its entire invocation replaced, including the trailing echo on 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, with next() 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:

  • Keep the shell-hook path alive as a compatibility layer for a while. A lot of us have real hooks in production.
  • Document the $ 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.

durban01 · 1 month ago

Feedback from someone maintaining a guardrails plugin built entirely on today's PreToolUse/PostToolUse command hooks (Anchorwatch, MIT). The proposal looks great; here's what the current hook system makes hard, in case it helps prioritise:

  1. Hooks fail open, invisibly. If my script crashes, times out, or the machine has no 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.
  2. Untyped stdin JSON. Every command hook re-implements parsing with a jq → node → python3 fallback chain. Typed inputs in a function hook remove that whole class of bugs.
  3. No dry-run harness. Testing a hook today means hand-crafting the JSON envelope. A claude hooks test <event> --input file.json that runs the real dispatch would let plugin authors ship CI-verified hooks.
  4. Compound commands. Guards must split on &&, ;, ||, | 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).
  5. Deny reasons are the best part. Feeding permissionDecisionReason back 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.json next 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.

poteat contributor · 1 month ago

@g-i-o-r-g-i-o

Can a plugin call $.fs.read on arbitrary project files, or only inside its own directory?

It can, unless your org admin has installed a plugin that hooks onto fs.read to restrict it. I'm renaming our internal prototype from fs.readFile to fs.read now, because it's internal, and it's pre-contract :)

@Spencer-Morley

Keep the shell-hook path alive as a compatibility layer for a while

Indeed, we've spent a lot of time thinking about this. Our internal prototype now introduces a classic.PreToolUse event (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:

// shell hooks only, no function hook interference
on("classic.*", ($, e, next) => {
  // e is a union of all classic hooks
  return next.to(e, "core")
})

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 call next, 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

For a redactor the interesting position is the opposite one, closest to the bottom

re this topic, I'd be interested for your thoughts on the next.to semantic as described above.

jfkconstruct · 1 month ago

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.

42tahara · 1 month ago

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 a tool.call hook and rewrote its regression suite against /plugin-types output. Feeding the hook our old hand-rolled fixture shape ({tool_name, tool_input}) fails tsc with 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. And classic.* wrapping the shell hooks 1:1 means we don't have to migrate to benefit — if e on classic.PreToolUse is 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.ts declares no way to run registered hooks in order, so a unit test can only call a hook with a stubbed next. 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 a reduceRight with accoutrements", my sense is the surface is already narrow enough to publish.

Spencer-Morley · 1 month ago

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.call hook and confirmed the deny path end to end, but left the compatibility half conditional, "if e on classic.PreToolUse is 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 case classic.* 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's hook.error event 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.to puts coverage and unskippability on one axis, pointing opposite ways.

Following @autorundev's point about redactor position: a masker wants to sit as close to core as 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 a prepend plugin removes with next.to(e, "core"). Move the masker up to prepend and 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.to clamps 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.

42tahara · 1 month ago

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:

// hooks-handlers/probe.ts
import type { Register } from "claude-code";
export const register: Register = (on) => {
  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
    if (e.command.includes("SLEEP")) await $.clock.sleep(60000); // budget overrun
    if (e.command.includes("SPIN"))  { const t = Date.now(); while (Date.now() - t < 60000) {} } // wedged
    return next(e);
  });
};
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p --plugin-dir <dir> --debug-file ./d.log "run: echo SLEEP"

All three skip causes are logged with the plugin name, and the tool ran in each case (--debug-file):

  • throw → hook failed: <plugin>: <msg> (tool.call; skipped; what is below it ran in its place)
  • budget overrun (awaits past its window) → hook failed: <plugin>: exceeded 10000ms budget (…skipped…), at 10s
  • wedged (spinning without yielding, so it can't observe its own abort) → hook failed: <plugin>: no answer to a heartbeat within 5000ms: the hooks worker is wedged (…skipped…), at 5s

So 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-file is exactly that: a first-party hook.error event or health signal (as @frsorrentino and @durban01 describe) would let a plugin act on a sibling's silence instead of tailing a log.

Spencer-Morley · 1 month ago

I said I'd measure rather than ask again. Rig: 2.1.263, macOS, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, one --plugin-dir probe plugin, claude -p, witnesses are --debug-file lines 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 PreToolUse handler for 600s to see it:

02:37:13.386 [DEBUG] [fh-timeout] $.ui.log: [stall] PreToolUse entered — about to hang for 600s
02:37:23.387 [ERROR] hook failed: fh-timeout: exceeded 10000ms budget
              (PreToolUse; skipped; what is below it ran in its place)
02:37:23.472 [DEBUG] hooks module fh-timeout PreToolUse settled in 10102.0ms (next() included)

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 gets settled 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.fetch to 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.create plus the capability names session.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 bare PreToolUse, and classic resolves as a capability namespace that some plugin has to provide. Registration accepts it and the $ build then refuses:

[DEBUG] hooks module fh-probe loaded (worker, environment 1);
        events: session.start,classic.PreToolUse,PreToolUse,tool.call
[ERROR] hooks module fh-probe could not build $: fh-probe: registered classic.PreToolUse,
        but no loaded plugin provides $.classic; unloaded, its withholdings kept while it
        is declared

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.call logs first and settles last, so it wraps PreToolUse. That bears on my other question, still open, about next.to putting a redactor's coverage and its unskippability on the same axis pointing opposite ways.

Untested by me: anything involving next.to or tier skipping, which needs two plugins and a $.classic provider I could not find.

durban01 · 1 month ago

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.to skipping forward, the property I'd want written down is that once any tier denies, no later tier (user or builtin) can reach core for 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 typed tool.call handler behind the flag, and the /plugin-types output finally gives the regression suite a real fixture shape. I'll report what the port surfaces.

yonatangross · 1 month ago

@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 modules entry it prints what the module registers and what it touches:

$ claude plugin validate ./probe
Validating hooks: ./probe/hooks/hooks.json
  > ./hooks-handlers/h.ts hooks: tool.call{}, tool.call{tool=Write}, tool.call{tool=Bash}
  > ./hooks-handlers/h.ts calls: $.ui.log

So you get the registered events with their matchers, and the $ capabilities called, which is the static side effect analysis @poteat described for plugin.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:

"zzz.not.an.event" is not an event; $ is always spelled $.noun.event(...)
at the call site, and on is always on("<event>", hook)

Two limits worth knowing before you trust it, both measured rather than read. It checks shape, not membership: banana.PreToolUse and $.zzz.nope() both validate clean, and the only bare name it accepts is PreToolUse. And it is not the runtime loader, so it will not reproduce the fold, the skip semantics, or next.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 modules key 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 are model.complete, model.classify, model.fork, audio.play, audio.speak, mcp.call, and seven session.* accessors (cwd, model, turnCount, id, messages, repo, surface). The event list has 20, not 19; ui.select is the extra. It is fs.readFile in 2.1.263, so the fs.read rename 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.log does not exist inside engine.create (undefined is not an object), which makes sense given that event builds $.

Spencer-Morley · 1 month ago

@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 between ui.input and agent.offer. The capability list lost its first thirteen entries: my published list began at session.authorize because 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:

grep -aoc '\[[^][]*"process\.run"\]' claude.exe   # 1, so there is one such array

20 events:

PreToolUse, tool.call, ui.render, ui.resolve, ui.press, ui.input, ui.select,
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.create

36 capabilities, in binary order:

model.complete, model.classify, model.fork, audio.play, audio.speak, mcp.call,
session.cwd, session.model, session.turnCount, session.id, session.messages,
session.repo, session.surface, session.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

fs.readFile confirmed on 2.1.263, so the fs.read rename 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 for fs.read: an org that restricts filesystem access has not thereby restricted a plugin from reading the transcript and posting it through http.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 -aoc on 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.

akshaypimprikar · 1 month ago

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 create fires.

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.

RossCoffeeDM · 1 month ago

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.

poteat contributor · 1 month ago

@Spencer-Morley

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

I would suggest a Promise.race a la the following:

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  const ok = await Promise.race([
    $.http.fetch("https://policy.internal/check", { method: "POST", body: e.input.command })
      .then(r => r.status === 200, () => false),
    $.clock.sleep(8_000).then(() => false),
  ])
  return ok ? next(e) : { deny: "policy did not approve in time" }
})

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:

// If a prepend plugin dispatches a $ call, skip all user plugins during that dispatch
on("*", ($, e, next) => next.origin.tier === "prepend" ? next.to(e, "append") : next(e))

(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 do on("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 for err as well, including a err.budget. or there might be a fourth next param 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 haha

I 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)

cmacguide · 1 month ago

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_HOOKS set in an isolated CLAUDE_CONFIG_DIR: a hooks/hooks.json with "modules": ["./index.ts"] loads, engine.create builds a $ for the plugin, a tool.call hook runs in a worker and $.ui.log reaches the session. hooksModule in plugin.json registers nothing, silently; the schema error on hooks.json was 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:

  1. Version the contract before shipping the engine. Adding one definition to our plugin currently breaks six count-assertions in three of our own test files, and one of them only runs in a CI job the release path never triggers. That cost is ours to carry. But a $ surface that renames between builds (fs.readFile → fs.read was mentioned above) moves the same cost to every claude plugin update. A contract field in the module, checked at load and refused on mismatch, would let us pin and let you evolve.
  1. Make budget overrun a first-class outcome, not a Promise.race idiom. 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 + one index.ts) if it helps.

StayBlue · 1 month ago

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.

heatherhelen · 1 month ago

@StayBlue Agent play time!

LukeMS · 1 month ago

Adding a measured case for the modifying placement, from a harness where deny and the product's
own 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
PreToolUse guard on Bash that 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 PreToolUse guard 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 modifying is the exact fit here, rather than just "deny is too blunt": the substitute is
already 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 modifying placement 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.

benmiller-SC · 1 month ago

Can we have it yesterday?
Something like this could dramatically improve a context window:

  • Hook tool.call for Bash; if text exceeds N bytes, stash in $.store (or a Map) under $out_1, return {

result: { stdout: "<stored as $out_1, 48KB, head: …>" } }.

  • Register a plugin tool ($.tool.register, l.1293–1308) for tail/slice/grep $out_1 so the model can

inspect on demand.

Lum1104 · 1 month ago

Awesome, can't wait to build something great upon this!

tusharsrivastava · 1 month ago

@poteat I am already tinkering with this feature using CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 I think one thing we should allow it is to let this code run:

export const register: Register = (on, options) => {
  on("session.start", async ($, e, next) => {
    // this doesn't run
    $.prompt.submit("/fetch-context");

    // this runs
    $.prompt.submit("fetch latest context");

    return next(e);
  });
};

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.

AI-Editor-79 · 1 month ago

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.

poteat contributor · 1 month ago

@benmiller-SC

Can we have it yesterday?

Stay tuned, I hope to give an update soon re timelines.

@tusharsrivastava

$.prompt.submit("/fetch-context")

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 for prompt.submit to only be submission of direct textual prompts to the model, rather than also an interface for executing commands / skills.

sholto-25 · 1 month ago

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.md is 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:

  1. Can I invoke a hook against a synthetic event, outside a live session, from CI? This is the big one. If exercising a hook means driving a real interactive session, I cannot validate it against known-bad input, and an unvalidated gate is prose with extra steps. A hook that's a pure function of a typed event should be the most testable thing in the system — please make sure the $ object is mockable and don't let the runtime coupling take that away.
  1. **Can a project require a hook, rather than merely offer one?** A control a contributor can decline isn't a control. The admin story in the proposal goes the other direction — restricting what plugins may do. I'd also want the inverse: a repo asserting "these hooks are mandatory in this workspace."
  1. Can fail-closed be declared? If the hook itself throws, does the tool call proceed? For a guard, failing open is the worst possible outcome and the quietest.
  1. Is there a fire count? A guard that has never fired is indistinguishable from a guard that is blind. Some way to ask "did this ever actually trigger" turns that from an assumption into an observation.

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 of CLAUDE.md into 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.

poteat contributor · 1 month ago

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-types will make available a full listing of the available $ affordances.

sirmaelstrom · 1 month ago

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:

  • A merge-notification title crossing from Git Bash argv into pwsh -File lost its arrows. vitest 4.1.8→4.1.11 posted as 4.1.84.1.11. Nothing errored, and the version number in the receipt was fabricated. (2026-09-09)
  • PowerShell 5.1 reads a UTF-8-no-BOM .ps1 as cp1252. An em dash inside a double-quoted string ends in byte 0x94, which 5.1 accepts as a closing smart quote, so the string terminates mid-sentence and the parser reports Unexpected token pointing 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 .cmd shim. 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)
  • Hedging that with shell: true concatenates argv into one command string, so a multi-line GraphQL argument reached gh in 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.

poteat contributor · 1 month ago

@sirmaelstrom

Is there room for a registration that declares independence

Indeed, you can run function hooks concurrently today by not calling await on next. If you kick off next early 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:

on('tool.check', async ($, e, next) => {
  const beneath = next(e)                 // everyone below starts now

  if (await isDestructive(e.input))       // my 300 ms overlaps theirs
    return { decision: 'deny', reason: 'destructive command' }

  return beneath
})

In this way, only operations which _must_ be run serially are done so. Although in this case you would want to use tool.check rather than tool.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.

VanguardBit · 1 month ago

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 on plugin.register that 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:

  1. At 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.
  2. Fire the same event, or a sibling event, when a skill or instruction file is loaded into context, not only when the plugin registers at startup. Load is the moment the bytes matter, and it is the only place a check can sit close enough to the read to avoid a verify-then-load/TOCTOU window.
  3. Document which paths inside an installed plugin directory belong to the host. Claude Code writes .in_use/<pid> and .orphaned_at into 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 through SessionStart and PreToolUse command hooks. Both fire after artifacts are resolved, so they report but not gate. A plugin.register with 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 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

ajbt200128 · 1 month ago

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.

addHook("tool.call", {tool: "bash"}, <combinator before, after etc.>, (args) -> {...})
addHookAround(tool.call",{tool: "bash"}, (orig_fun, args) -> {...}

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!

sirmaelstrom · 1 month ago

@poteat I overstated the serialization point. Starting next(e) before the local check lets the waits overlap; the missing piece for guards is tool.check, since next on tool.call can 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 say next.signal aborts 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.check in the types I generated from 2.1.267, so I'm treating that part as forthcoming.

poteat contributor · 1 month ago

@ajbt200128

how do I as a user ensure they are called in the correct order?

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.

it would be nice if plugins could yield in non tail positions

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:

// make all model text output rot13
on('turn.step', async function* ($, e, next) {
  const stream = next(e)
  for await (const chunk of stream) {
    yield chunk.kind === 'text'
      ? { kind: 'text', index: chunk.index, text: rot13(chunk.text) }
      : chunk
  }
  return stream.result
})

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 next directly.

@sirmaelstrom

when the outer check denies after starting next(e), who owns the outstanding downstream promise?

If I understand the question: the plugins still do. They can still call $ etc. or float arbitrarily (this is how timers work). next.signal is merely a signal that the dispatch has been abandoned, the host will not be doing anything with your return value.

sirmaelstrom · 1 month ago

@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.

wtchronos · 1 month ago

**** 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.

sezaakgun · 1 month ago

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.render for AbovePrompt with one Client module per board, turn.complete to pause the game when Claude finishes, tool.call to feed a pet, $.store for scores. The API held up; the things that cost me time were the h local-variable rule in surface modules, Client module 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.

backnotprop · 1 month ago

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.

TheLazyIndianTechie · 1 month ago

This is amazing stuff!

Arunjay4213 · 1 month ago

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 /context as 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.every tick, kept in $.store across 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

claude plugin marketplace add Arunjay4213/claude-mods
claude plugin install quota-meter@claude-mods   # needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Things we hit on 2.1.270, in case they are useful:

  1. On 2.1.270, $.session.usage({ breakdown: "full" }) was declared in the d.ts but returned no breakdown key, for summary and full alike. 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.
  2. There is no way to ask whether a pane is open. After a hot reload the module loses its flag while the pane stays up, so the ui.render hook has to re-learn it. A $.ui.panes(), or an isOpen on what $.ui.open resolves to, would remove that.
  3. rateLimits[].percentUsed arrives as floats like 7.000000000000001; whole numbers would match what the API means.
  4. Nothing pushes a rate-limit change, so a tracker has to poll $.session.usage() on a timer. A session.usage event would let it sleep.
  5. $.ui.log is 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.

lperezmo · 1 month ago

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 -p always 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 test does not exist in 2.1.270. mods/README.md documents it and the helpers do import from claude-code/testing

3. claude plugin validate is the best onboarding tool in the CLI and is not where a newcomer finds it. It prints every hook and every $.noun.verb the 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), but McpToolCallInputFallback sits nearby in the .d.ts and reads like its argument object, so my first call passed a single object and got mcp.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

Automage · 1 month ago

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.

frsorrentino · 1 month ago

Two late data points.

Item 4, narrowed. @Arunjay4213 shows $.session already exposing rateLimits[].percentUsed and 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.

wkasekende · 1 month ago

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 command and 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.fill and prompt.suggest are only raised by plugin calls (PromptFillOrigin: the engine's kind is reserved for sites "none of which raises prompt.fill as shipped"), ui.input is only an Input a render hook drew, Client.onKey needs a click to take the focus, ButtonProps.action binds only an engine action name ("unknown names refused"), and the draft is first visible at prompt.submit. In the cheat sheet's own shape:

on("prompt.edit", { key: "up", ctrl: true, meta: true }, ($, e, next) =>
  cycle(e) ? { text: e.text, cursor: e.cursor, decorations: paint(e) } : next(e))

// e: { key, ctrl?, shift?, meta?, text, cursor, selection?, origin }
//    the keystroke as ClientKeyEvent names it, and the box as it stands; origin
//    { kind: "composer" } or { kind: "plugin", name }, pinned
// next(e): the engine's own editing, resolving { text, cursor, decorations? }
// next({ ...e, key }) rewrites the keystroke; { ...r, text, cursor } rewrites the edit
// an answer without next consumes the key (a chord, a `_` gate)
// coalesced to one dispatch per frame, as ui.message is; cursor moves are edits too

Two things ride on the result:

  • decorations: ranges of the box styled with the TextProps allowlist 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.fill overwriting the whole box is the only way to show anything, and prompt.suggest is { isShown: false } "while the box holds text", which rules out ghost text next to what the person already wrote.
  • A write with no keystroke behind it. A model answer lands 300 ms after the _. $.prompt.fill needs cursor? and decorations? on PromptFillArgs (today it always ends "cursor at its end", so it cannot splice a word), and prompt.edit on the InvalidatableEventName list so $.ui.invalidate("prompt.edit") re-dispatches the last edit with no key for a repaint. Our own hooks see that fill with origin.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, so sec-default strips it and an org keeps user-tier plugins out of what the person types.

Everything else is already there. $.ui.status for our status line, $.command.register for /opencues, $.fs.read / $.fs.list with the process's reach for ~/.cues hot-reload, $.process.run for a blank's script, $.http.fetch and $.env.get for a provider key, $.store for per-plugin state, $.clock.every for the debounce, $.audio.speak for voice mode, $.session.messages for reading the transcript instead of scraping the jsonl, and $.model.complete / $.model.classify so 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. With prompt.edit the whole runtime moves over unchanged, and I will test it under --plugin-dir the day it exists.

halluton · 1 month ago

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.

Butanium · 1 month ago

One gap, from reading mods/types/claude-code.d.ts and 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 and isMeta user 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.step pins the messages (messageCount: "the messages are the engine's"); only model and effort are rewritable.
  • prompt.context fires once per conversation and only covers the first user message's blocks.
  • prompt.submit receives the typed text. In the 2.1.271 dispatch, a rewritten text is applied by walking the messages and skipping anything that is an attachment or an isMeta user message; context is 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, after prompt.submit and between tool calls where nothing is submitted.
  • prompt.section is 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 } with null to leave it out, the way prompt.section does. 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=off makes 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 shouldDefer set and are only loaded through ToolSearch. Monitor is one: every session that wants to arm a watcher first spends a turn on ToolSearch select:Monitor, and every hint that mentions the tool has to say so. tool.describe only answers { description }, and tool.list only lists, so a plugin cannot change the deferral. A field on tool.describe's result (isDeferred), or a tool.offer event shaped like agent.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 a reason when 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.receive exposes a delivery as { origin, text } only, and session.send is reserved. Exposing the frame (kind and its fields) on session.receive, and a session.send event 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 (a reason on shutdown_approved) instead of patching the sanitizer. Whether the in-process team mailbox goes through session.receive at 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>

nateschickler0 · 1 month ago

@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 Client module'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 of Client instances, one strip each; and the hooks environment has no WebAssembly, so the engine (diekmann's wasm build of linuxdoom-1.10, fetched on first start) runs in a process of its own under bun or node, reached through $.http.fetch on 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.

maddiedreese · 1 month ago

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-storytime then /plugin install storytime@cc-storytime, under CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

What it exercises: ui.render on AbovePrompt wrapping next(e) so it stacks with other bands; turn.start and tool.call for the seed (the tool picks the hero, the file it touched names the hero); $.clock.every ticking generation with $.ui.invalidate; $.store for the on/off state; command.register for /story. claude plugin test with mock.clock was enough to test the whole thing, model included.

Things worth knowing if you try something similar (from Claude):

  • The hooks environment is plain ES2023 on web globals, which is fine for this: a run.c port on Float32Array needs nothing else. WebAssembly is not in the declared globals, so I did not lean on it. Typed arrays alone gave a few thousand tok/s.
  • Getting weights in is the awkward part. $.fs.read is 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 .ts module. A { asset }-style bytes read, or an ArrayBuffer return on fs.read, would be the nicer path.
  • Hook budgets are enforced but the numbers are not in the declarations, so I sized the work per tick rather than per call. Documenting the budget per event would let plugins plan.
  • Small things that worked: .ts extensions in imports, a marketplace whose source is "./" so the repo is both the plugin and its marketplace, and the typed on('tool.call', ...) narrowing.

Loving trying out Mods!

  • Maddie and Claude
Sureffi · 1 month ago

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.render on AssistantMessage hands me the whole block and viewport.columns, so the state file keyed by message id goes, resize re-draws, and --resume shows 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 have Svg. 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 a Text element'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 Svg already fills on desktop.

Raster doesn'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.

karanb192 · 29 days ago

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 validate on every plugin.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.

Marat · 29 days ago

Feedback from a heavy mod user: five places where the surface works against its own goals

Edited shortly after posting. Point 5 originally claimed that a mod cannot see the session's own model request. That was wrong — turn.step is exactly that hook, and I had missed it. The claim is retracted and point 5 is rewritten below into what I think is actually missing. Point 3 is sharpened with what turn.step shows. Apologies for the noise; better a corrected comment than a wrong one left standing.

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.spawn hook: 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: for sec-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.race under 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 any turn.step hook 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.complete cannot finish inside the budget it is given, and is the only model call without effort.

export type ModelCompleteRequest = {
    model: string;
    prompt: string;
    system?: string;
    maxTokens?: number;   // Default 256.
};

Four fields: no effort, no timeoutMs. Contrast TurnStepInput, where effort is a first-class, explicitly rewritable request scalar:

/** … the session's setting or the model's default, absent for a model
 *  without effort; rewritable. */
effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | number;

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? on ModelCompleteRequest, with the same shape it has on TurnStepInput; then timeoutMs?, so the mod can bound the call to fit its own budget instead of being killed mid-flight.

4. prompt: string is 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. maxTokens defaulting 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.step can replace a model request but cannot forward one.

(This replaces the original point 5, which wrongly said no such hook existed.)

turn.step gives a hook the request "at the moment the engine is about to send it", lets it rewrite model and effort, and — by not calling next — 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. TurnStepInput carries turnId, index, model, effort, messageCount and agentId; 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:

  • expose the composed request on TurnStepInput (read-only is enough for the audit half), or
  • allow the destination to be rewritten the way model and effort already are, so forwarding is a change of address rather than a rewrite of the payload.

One smaller thing. $.model.fork takes only { prompt } — no model choice — and ModelForkUsage hands 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.

djscruggs · 29 days ago

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 PreToolUse command 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. $.fs has 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.

$.fs exposes read, write, list, exists, stat, and ancestors. 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.kind is '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.read takes "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 a resolveSymlinks option 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 .catch on both throw and timeout. 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; and claude plugin test, so guards get exercised before they ship.

Happy to test a build against the ported guard if that's useful.

frsorrentino · 29 days ago

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-pane calls 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.complete for busy and idle, tool.call on AskUserQuestion for 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. RenderSurface is terminal | desktop | mobile, and the roster that session.attach reports 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.detach fires per client with reason: 'end', but the session has no end event of its own carrying why it ended (exit, /clear, crash, killed) and the id --resume takes. 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.receive already 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 rateLimits is readable: a threshold event. The watch's quota complication and our quota guard both poll for it today.

rjwittams · 29 days ago

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.

  1. Safe moments to write. Two processes write to one tty. When the terminal falls behind, the kernel splits whichever write is in flight and the other's bytes land inside it: we see corrupted placeholders and half escape sequences. What would fix it is a post-render hook, run after Claude Code has flushed a frame and before it writes again, where a plugin can emit bytes to the terminal, or name a process that may. That is the one primitive the design cannot do without; everything else below is smaller.
  2. A repaint signal. A resize or full repaint in fullscreen mode clears the screen and drops terminal images. The plugin gets no event, so the external process cannot know to re-send. A hook on full redraw, or a ui.render re-run after one, would do.
  3. Mouse in the default mode. Mouse reporting is on only on the alternate screen, so band buttons and clicks into a Client never fire outside fullscreen. Mouse events in both modes for components that opt in. Maybe this is more annoying than it sounds!
  4. A handle to a local process. $.process.run waits for exit; no socket, no pipe (a named pipe read returns empty). The plugin polls its helper over loopback HTTP on a timer, including for input relay. A child or socket handle a hook can read and write would replace the polling.
  5. Session end. No hook fires when the session ends, so a helper started by the plugin outlives Claude Code. We pass Claude Code's pid to it as a workaround.
  6. Image content in tool results. A tool that screenshots a panel can only return a PNG path; the model spends a second call on Read. Image blocks in a plugin-served tool result would remove that.
mpolatcan · 29 days ago

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.render on Pane, with $.ui.resolve read loosely so a build without Raster or Button degrades instead of failing. Everything that moves lives in a Raster repainted through $.ui.blit (~120 ms a frame, against ~320 for a re-render). Because a Raster is a leaf and Box cannot overlap, the picture is cut into bands: label rows are Text + Button, every run of rows between them stays a Raster.

The gap that cost us most is scrolling. ui.scroll is 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: a Button carries 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.scroll first.

kunchenguid · 29 days ago

Feedback: two transcript render sites bypass ui.render on Claude Code 2.1.273

Build: 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.render and draws zero-height boxes for rows the reader has opted out of
seeing, leaving the stored transcript, the model context, and every non-presentation code
path untouched.
This works well for ToolUse, ToolResult, ToolGroup, UserMessage, and
AssistantMessage.

Two remaining rows cannot be reached, because they draw without emitting a ui.render
event at all.

Observation 1: task-notification rows emit no ui.render event

Notification rows such as Stop hook feedback and
Background command "..." completed (exit code 0) appear in the transcript but produce no
render event, so no component hook can see or rewrite them.
In particular they are not UserMessage instances, so reading
props.origin.kind === "task-notification" on a UserMessage hook does not reach them.

Reproduced three ways, all with the same result:

  • a Stop hook declared with "asyncRewake": true that writes to stderr and exits 2,
  • a background Bash task completing while the session is idle,
  • a background Bash task completing while a foreground turn is still running.

Method: a plugin registering a catch-all ui.render hook with no component matcher, which
records every event and then calls next(e).
The capture holds 99 consecutive events with no gaps in the sequence.
All 3 UserMessage events in it were typed prompts carrying origin: {"kind": "composer"}.

Observation 2: the background-task pill is not in PromptHint.hint

The (ctrl+b to run in background) pill drawn under a long-running shell command is not
carried in PromptHint.hint, and emits no render event of its own.

Every distinct PromptHint props 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 shell pill is present, confirming the hint line was tracking the live shell at the
time 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 equivalent backgroundTasksDisabled
setting), 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:

  1. A hookable component for the notification summary row, carrying the summary text and

the origin kind in its props.

  1. A hookable component for tool-progress elements, including the background-task hint,

or inclusion of that hint in PromptHint.hint so 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 chord
is 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.

meganemura · 29 days ago

Feedback from someone who shipped a real Pane + Client mod 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. Three
things are still real friction today, building UI rather than hooking commands.

1. Testing a Client-based mod.

  • Still painful: claude plugin test's kit cannot drive a Client instance's pointer events,

or raise a ui.message event at all (EventCalls['ui'] only has
render/resolve/scroll/focus). We hand-rolled a ClientSurface double to test our
pointer logic directly, and test the hooks-side ui.message handler only as plain functions,
never end to end.

  • Why: no shipped mod under mods/* uses Client at all, so there's no existing test to copy

from either.

  • What would help: a mods/ example that uses Client, or a test-kit primitive (or documented

pattern) for driving Client pointer events and ui.message.

2. prompt.fill can't append or read the box.

  • Still painful: PromptFillInput is { text, origin } only, and it always replaces the whole

box. There's also no way to read what's currently in the box.

  • Why: for a "quote this into the prompt" feature, a press throws away whatever the person was

already typing, with no way to even detect that there was something there to lose.

  • What would help: an append/insert mode, or a way to read the box's current text. Short of

that, naming prompt.submit's context field as the documented alternative directly in
prompt.fill's own JSDoc would save the trip through mods/diff's source we had to take.

3. A Pane Button's hotkey does nothing. Is that intentional?

  • Still true today: set hotkey: '1'/'2' on two Buttons, confirmed in two terminals: nothing

happens on the keypress; arrow-keys-plus-Enter already move focus and press.

  • The tension: for a Pane with several entries, a working hotkey would genuinely help, since

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-arcade flagged for the
AbovePrompt band it does work in: it collides with typing a digit as the first character of
a prompt. So "inert in a Pane" may be deliberate, not an oversight.

  • What would help: a yes/no on which it is. If deliberate, say so in the hotkey JSDoc so the

next 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" />

x-itm · 28 days ago

Regarding the Express/Koa-style registration-order continuation model (next() wrapping): when multiple mods hook into overlapping events like ui.render or PreToolUse, order sensitivity becomes critical for deterministic side-effect tracking. If an early middleware swallows or alters the parameterized $ object without calling next(), 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.

poteat contributor · 28 days ago

@backnotprop

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.

Many events may indeed be fired from hooks, i.e. you can call $.prompt.submit anywhere. I definitely want Mods to operate in the way you're saying.

@nateschickler0 and @maddiedreese

WebAssembly et al

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

fail-closed etc.

Have you come across the on(...).catch(...) spelling yet? I think this would address your use-case re fail-closed / fail-open.

@x-itm

endomorphic chain aspects

Have you looked at next.trace? I'm curious if that has enough data for your use case.

backnotprop · 28 days ago

@poteat

Many events may indeed be fired from hooks, i.e. you can call $.prompt.submit anywhere. I definitely want Mods to operate in the way you're saying.

Nice! Even if the agent state is idle (i.e. same way a monitor can wake an idle agent)? If so, chefs kiss 🤌

x-itm · 28 days ago

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.

RobPruzan · 27 days ago

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!

Faridmurzone · 27 days ago

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" />

karanb192 · 27 days ago

$.model.fork reused 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" />

/keepwarm arms 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.submit answering { drop }); resend to proceed.

Three API findings

  1. Forks can reuse the cached prefix. The types describe the transcript sharing; cache reuse needed a live test because the mock kit cannot prove it. Worth documenting explicitly.
  1. Turn usage is not context size. Summed turn.complete usage overstated my cold estimate about tenfold. $.session.usage().context.tokens gave me the current context size.
  1. Compaction and clear leave gaps. On 2.1.272, compaction usage reached none of my registered hooks; after /clear, my Mod has no history. A cache expires_at on $.session would 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.

Danm72 · 27 days ago

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.register gives the model a rename_session tool. $.command.run({ command: 'rename' }) applies it on turn.complete. /color runs the same way at start, with a colour no other live session uses.

A prompt.submit hook adds context so every reply ends on a needs 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. RenderComponent stops at Pane and AbovePrompt. The fleet screen (FleetViewScreen, mountFleetView) draws without a ui.render site. 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 (or FleetView) render site, with the same contract as ToolUse rows? 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.

fragmentsstudio · 27 days ago

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.call hook 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 a PreToolUse command 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 a Read whose path carries a marker. Same file, two callers:

| caller | outcome | keys on e |
|---|---|---|
| the session itself | denied | file_path, tool, tool_use_id |
| an Explore subagent | denied | file_path, tool, tool_use_id, agentId |

So: yes, and the hook can tell. agentId is 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 Agent call 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 real Read.

---

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: "..." } prints

mods-probe: tool.call hook skipped: returned the wrong shape (neither { result } nor { deny })

and 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-file line at minimum), and let claude plugin validate catch a handler whose return type is neither, since it already parses the module well enough to report hooks: tool.call.

2. Naming hooks/hooks.json in the manifest kills the entire hook load. hooks/hooks.json is picked up automatically; adding "hooks": "./hooks/hooks.json" to plugin.json produces

[ERROR] Duplicate hooks file detected ... The standard hooks/hooks.json is loaded automatically
[DEBUG] Plugin not available for MCP: <name>@inline - error type: hook-load-failed

None of that appears without --debug-file; in the session the plugin simply does nothing. claude plugin validate passes 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-authoring skill for a minimal plugin, and it produced the manifest with the hooks key. 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 says plugin.register: ... admitted, and no tool call ever reaches the handler — including with a hook that denies everything unconditionally. The binary carries Function 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 in claude plugin eval) would close that.

tonydzi · 27 days ago

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 -p at 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.register to 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.

AlmogBaku · 27 days ago

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 returns null on a cold snapshot — arm at session.start, fire at the first warm moment.
  • Subagent transcripts are unreachable: $.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.
  • Input is hostile to steering: focusing a field you just opened is a multi-frame dance (open({focus}) refused, autoFocus only on a "take", $.ui.focus() refuses uncommitted elements), it's one line with no cancel, and pre-filling it hides keystrokes past the truncation — use placeholder. Asks: autoFocus on next commit, Shift+Enter, Esc, and plugin test driving focus/keys.

MIT: https://github.com/AlmogBaku/ContextSaver

ajwillia · 27 days ago

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.compact and session.end worked in -p and interactive runs. That covers /clear, /exit, resume, and sessions driven entirely by channel notifications, which our shell SessionEnd hook handled badly. /plugin-types plus tsc catching a misspelled event name made this pleasant.

The gap: there's no way to get the transcript file path from $. Classic hook inputs carry transcript_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 whether cwd() or root() is the right key after /cd or a worktree move. And it breaks if the layout changes.

Asks, any one of which would do:

  • $.session.transcriptPath(), or transcriptPath on the session.end / turn.complete / session.compact inputs.
  • A way to read the raw transcript records, not only the summarized messages().

Two smaller notes:

  • The session.end bound (1.5 s default) cut off a normal HTTP upload. $.env.set('CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS', …) in session.start raised it, and so did a settings env block. That works, but it's undocumented on the mod surface. A per-plugin bound, or a documented way to request one, would be clearer.
  • The published mods/types/claude-code.d.ts doesn't list session.end, but the 2.1.275 /plugin-types output does. We treated the local output as authoritative. Worth noting in the README for people reading the repo copy.
FazalAAli · 27 days ago

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 pi subagent type and replaces only that subagent's turn.step with a detached pi --mode json run. 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:

  • 10s hook budget. A long-running step can't finish in one call, so I stream for ~7s, end the step on a no-op registered tool (pi_progress), and chain the run across steps. A supported way for a turn.step hook to run long, or to yield without a fake tool call, would remove that workaround.
  • Subagent follow-ups. Messages sent to a running subagent aren't in $.session.messages(), so I read subagents/agent-<id>.jsonl under ~/.claude/projects/ and poll for them. An API for a subagent's own transcript would make this robust.
  • Teammate detection. A teammate process has no API for its own agent id or type, so I read them from ps. Exposing those on $ would help.

Thanks for opening this up. Mods made something possible that plugins alone couldn't do.

poteat contributor · 26 days ago

@AlmogBaku

Subagent transcripts are unreachable

Indeed, we'll fix this. I think an optional agentId key on the event would be reasonable.

@ajwillia

There's no way to get the transcript file path

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.

A per-plugin bound, or a documented way to request one, would be clearer.

I believe this information may be present on next.budget, although it's misleading for the session.end event right now, as there exists the separate criterion that we want exits to be fast no matter what mods are loaded. I'll ensure next.budget is 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.

edburns · 26 days ago

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.

poteat contributor · 26 days ago

@edburns

Is the ordered loading of mods completely deterministic?

Yes. The order is always in the following order:

  • [org-prepend] [user] [org-append] [built-in]

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.

m13v · 26 days ago

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.

poteat contributor · 26 days ago

@m13v

worth [..] feeding it off the raw pre-composition event stream

Indeed, folks brought this up early on on the thread and we hence introduced next.trace to 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.

musharna · 26 days ago

Two findings from building mods, on 2.1.277 and 2.1.278:

1. In a subagent, next(e) resolves with result: undefined. A main-loop Read returns the structured record (result.type === "text", file.startLine, ...). The same Read from a subagent has result undefined; text and ref are still set. The types say core returns { ref, result, text }. My mod read result.file.startLine, threw inside subagents, and the hook was skipped with no trace in the transcript or -p output. Could subagents get the same result, or the types say it's absent?

2. claude plugin validate shows a variable matcher as a literal. for (const tool of ["Bash"]) on("tool.call", { tool }, ...) is listed as tool.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.call and session.compact hooks fire under claude -p for me on these versions.

poteat contributor · 26 days ago

@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 displayed tool.call{tool=?} but I think I can safely have a matcher for the particular syntax of looping over a literal array.

konsta95 · 24 days ago

@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-coordinator MCP channel into Claude. The areas most relevant to Mods are:

  • Lifecycle continuity: persistent tasks, scoped decision history, session recovery and explicit handoffs that preserve the context needed to continue work.
  • Composition and enforcement: shared lifecycle logic across harnesses, clear authority between agents, and checks tied to artifact versions and review state.
  • Verification and operational learning: reproducible probes and simulations, known-bad controls, replayable evidence, transcript analysis and a controlled path from useful scripts to reusable tools.

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.

konsta95 · 23 days ago

Something concrete on the kit, out of #95423. That PR's mayHaveWritten reads isReadOnly off the shell tool's tool.call result, and its test passes because the fixture READ_ONLY_ANSWER supplies the field: a test's on('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 Bash tool.call result carries isReadOnly. 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.start calls $.tool.call({ tool: 'Bash', command: 'ls' }) itself, with no model in the loop, and a tool.call{tool=Bash} hook records the keys of every answer beneath it. It carries its own control: a failing ls shows isError: true arriving, 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.json

{
  "name": "readonly-probe",
  "version": "0.2.0",
  "description": "Records the keys of a Bash tool.call result as the live engine hands them to a hook, to observe whether the producer sets isReadOnly on the result.",
  "author": { "name": "konsta95" }
}

hooks/hooks.json

{
  "description": "One session.start hook that runs Bash ls through the engine and records the result's keys; one tool.call hook on Bash that records the keys of every answer beneath it.",
  "modules": ["./probe.ts"]
}

hooks/probe.ts

import type { On } from 'claude-code'

// Relative to the session's working directory: run `claude -p` from the plugin dir.
const OUT = 'probe-out'

let hooked = 0

function shapeOf(answer: unknown): Record<string, unknown> {
  const record = (answer ?? {}) as Record<string, unknown>

  return {
    keys: Object.keys(record),
    isReadOnly: 'isReadOnly' in record ? record['isReadOnly'] : 'ABSENT',
    deny: 'deny' in record ? record['deny'] : 'ABSENT',
    isError: 'isError' in record ? record['isError'] : 'ABSENT',
  }
}

export function register(on: On) {
  on('session.start', async ($, e, next) => {
    const started = await next(e)

    try {
      const answer = await $.tool.call({ tool: 'Bash', command: 'ls' })

      await $.fs.write(
        OUT + '/direct-ls.json',
        JSON.stringify({ via: 'session.start $.tool.call', ...shapeOf(answer) }),
      )
    } catch (err) {
      await $.fs.write(OUT + '/direct-ls.error.txt', String(err))
    }

    return started
  })

  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    const answer = await next(e)
    const command = (e as { command?: string }).command ?? ''

    hooked += 1

    await $.fs.write(
      OUT + '/hooked-' + String(hooked) + '.json',
      JSON.stringify({ via: 'tool.call hook', command, ...shapeOf(answer) }),
    )

    return answer
  })
}

Run from the plugin directory, then read probe-out/*.json:

mkdir -p probe-out
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "Run exactly two shell commands, one per tool call, then stop: first \`ls\`, then \`ls /nonexistent-probe-dir-7259\`. Do not run anything else." --plugin-dir "$PWD" --model claude-haiku-4-5-20251001 --max-turns 3 --dangerously-skip-permissions --output-format json > probe-out/run.json

What it wrote on 2.1.278:

{"via":"session.start $.tool.call","keys":["result","text"],"isReadOnly":"ABSENT","deny":"ABSENT","isError":"ABSENT"}
{"via":"tool.call hook","command":"ls","keys":["ref","result","text"],"isReadOnly":"ABSENT","deny":"ABSENT","isError":"ABSENT"}
{"via":"tool.call hook","command":"ls","keys":["ref","result","text"],"isReadOnly":"ABSENT","deny":"ABSENT","isError":"ABSENT"}
{"via":"tool.call hook","command":"ls /nonexistent-probe-dir-7259","keys":["ref","result","text","isError"],"isReadOnly":"ABSENT","deny":"ABSENT","isError":true}

</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_ANSWER is 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 an Object.assign around the type is visible. @musharna's result: undefined in subagents above is the same gap from the other side: the kit cannot see what the producer actually does.

edburns · 23 days ago
@edburns > Is the ordered loading of mods completely deterministic? Yes. The order is always in the following order: * [org-prepend] [user] [org-append] [built-in] 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.

Thanks for that Raymond Chen link. I'm happy to observe he is still working here. We've let so many good ones go.

7487 contributor · 23 days ago

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?

  1. diff: forced Git colors produce an empty diff body. With color.ui=always or color.diff=always, change counts remain correct but ANSI escapes prevent hunk headers from being parsed. The patch adds --no-color to 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 fa775ed9c9f332c20c8c0e4898cd56b7fccd5a73

  1. agents-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 e704c658ec891ebeb28a6d274f34b4c1dd4e1cf5

Each patch changes only its implementation and regression test. TypeScript checks, plugin validation and git diff --check pass 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.

konsta95 · 22 days ago

Three engine observations from building a /diff tmux variant on mods/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/diff on main (b486776) links 506: a seventh added file fails, naming hooks/array-of/array-of.ts. On #94847's head (23a906a) it links 511: a second added file fails, naming the same array-of.ts, and a third names hooks/git/tiers/with-untracked/merged-with-untracked/merged-with-untracked.ts instead, 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.ts nor the docs (git grep over 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/diff is 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's command.run hook on that name is not dispatched for it, and its session.start log 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.ts says, so a variant of a built-in lives on a command.run hook on the built-in's name, which is the hook that gap does not reach.

<details>
<summary>measurements</summary>

Cap: extract mods/diff at a ref, import a barrel that imports N one-line files, run claude plugin validate, take the first N that fails (files added = N + 1).

git archive "$REF" mods/diff | tar -x -C "$W"; M="$W/mods/diff"
mkdir -p "$M/hooks/probe"
for i in $(seq 1 12); do printf 'export const p%s = %s\n' "$i" "$i" > "$M/hooks/probe/p$i.ts"; done
sed -i "1i import './probe/index.js'" "$M/hooks/register.ts"
for n in 0 2 4 6 8 10 11; do
  { for i in $(seq 1 "$n"); do printf "import './p%s.js'\n" "$i"; done; echo 'export {}'; } > "$M/hooks/probe/index.ts"
  CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate "$M" > /dev/null 2>&1; echo "files_added=$((n + 1)) rc=$?"
done

| 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=512 beside the message template.

Load order: claude --plugin-dir <mod> on a private tmux server from a git repository, /diff tmux sent once the prompt is on screen and Enter 0.1 s or 0.7 s later. The module carries $.ui.log at the top of session.start and in its command.run hook, Date.now() at module top level, and a file write of that and the hook's own time from session.start. Screen after the first call, main's mods/diff:

❯ /diff tmux
  ⎿  Diff panel shown
● diff: probe: session.start begin

No command.run line for that call; the second call logs args="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 in claude -p, three runs). A one-file module hooking the same name: evaluated at 0.92 to 0.95 s, and its session.start and command.run lines both sit above the first reply at either delay.
</details>

konsta95 · 21 days ago

One more engine observation, from the status line mod, on 2.1.280, measurements below.

A hooked PromptHint draws one frame with the engine's own hint row stacked over the hook's previous tree, every time it is dispatched again. Each new ui.render dispatch of the line under the prompt, whether a props change (esc to interrupt appearing 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 hides esc 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.render hook; the "timer" rows add the session.start hook:

import type { On } from 'claude-code'

export function register(on: On) {
  on('session.start', async ($, e, next) => {
    const result = await next(e)
    $.clock.every(500, () => $.ui.invalidate('ui.render'))
    return result
  })
  on('ui.render', { component: 'PromptHint' }, ($, e) => {
    const { Box, Text } = $.ui.resolve(e)
    return Box({ flexDirection: 'row', children: [Text({ children: 'PROBEBAR|fixed|tree', wrap: 'truncate' }), Text({ children: '  ' + e.props.hint, dimColor: true, wrap: 'truncate' })] })
  })
}

Method: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir <probe> --model claude-haiku-4-5-20251001 on 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 each ESC[?25h (the renderer brackets a frame in ESC[?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 for echo one to echo six as six Bash calls, under --allowedTools Bash(echo:*). The typing session sends a prompt for a 1500-word story and, while it streams, types hello there, world, three backspaces and abc.

| 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):

frame 28  r65 ────────────── …
          r66 ❯
          r67 ────────────── …
          r68   ⏸ manual mode on · PROBEBAR|fixed|tree  esc to interrupt
frame 29  r64 ────────────── …
          r65 ❯ h
          r66 ────────────── …
          r67   ⏸ manual mode on
          r68   PROBEBAR|fixed|tree  esc to interrupt
frame 30  r65 ────────────── …
          r66 ❯ h
          r67 ────────────── …
          r68   ⏸ manual mode on · PROBEBAR|fixed|tree

With the prompt holding text, the turn's end drew one too with nothing visible changed, the probe included: of the three PromptHint props, only isWorking moves there.
</details>

konsta95 · 21 days ago

One more from the status line mod, also on 2.1.280, measurements below.

After Esc interrupts a turn, $.session.usage() answers context with no tokens or percent, where the classic status line payload keeps its figure. turn.complete arrives with isAborted: true and reason: 'aborted', and the first $.session.usage() read after it has window alone, 49 ms after a read at the step's end had tokens 37393. The typings call tokens the status line's total_input_tokens and 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 had total_input_tokens 37191 and used_percentage 19. 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 when turn.complete says isAborted; 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.step and turn.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 | tokens present, 6 of 6 (37088 to 37110) |
| interrupted while it streams by the driver's closing Esc and ctrl+c, 6 runs | tokens absent, 6 of 6 |

A short turn, then the story interrupted with Esc (ms since the module loaded):

 7870  turn.complete                  isAborted false, reason 'answer'
 7880  usage read                     tokens 37083
16623  usage read at the step's end   tokens 37393
16655  turn.complete                  isAborted true, reason 'aborted', usage all 0
16672  usage read                     window 200000, no tokens, no percent

The same two turns with the story left to finish: reason: 'answer', and the read after it had tokens 37371.

Classic payload: the same two turns, the story interrupted with Esc, with a module that hooks nothing that draws and statusLine running a script that saved every payload. The last of the five, sent at the interrupt, still had context_window.total_input_tokens 37191 and used_percentage 19, 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>

Sharkall · 21 days ago

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:

  1. $.session.microcompact({ keepRecent?: number }) returning { clearedIds, tokensSaved }, callable

from e.g. turn.complete after checking $.session.usage; or

  1. a session.microcompact event 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!

poteat contributor · 21 days ago

@konsta95

A hooks module links at most 512 files, and the refusal names an unrelated one. Past 512 the loader drops the whole module

Good call, I think a file limit is inappropriate and arbitrary, this will be removed. As an aside, thanks for playing around the diff mod - 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

Please let me know whether these fixes are in scope and how you would prefer to receive them.

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.

tre-dev · 20 days ago

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=AMAZING

https://github.com/user-attachments/assets/4ab5b14b-4696-45ad-b72b-c656584c6025

lperezmo · 20 days ago

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:

  • No single place to filter what the model reads. turn.step has no messages, so it took hooks on ten events. One hook on the outgoing request would be simpler.
  • boolean userConfig on the /plugin configure screen is a text box, and typing y saves false. /config gets it right.
  • Slash-command args go into the transcript, so a command can't take private input.
  • claude plugin test can't set userConfig values.
wkasekende · 19 days ago

Following up on my comment above: thank you for prompt.edit in 2.1.280. We are integrating OpenCues as a mod on it now, retiring our binary patch. prompt.edit reads the draft and takes the _, PromptHint draws the note under the box, and $.model.complete writes 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. PromptEditResult is PromptBox, { 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 decorations on what prompt.edit answers, and on PromptFillArgs for a write that lands after the keystroke (a model answer 300 ms after the _).

type PromptDecoration = { start: number; end: number }
  & Pick<TextProps, 'color' | 'backgroundColor' | 'dimColor' | 'bold' | 'italic' | 'underline' | 'strikethrough'>

// PromptEditResult = PromptBox & { decorations?: PromptDecoration[] }
// PromptFillArgs gains decorations?: PromptDecoration[]
  • Offsets into text, styles from the TextProps allowlist you already have.
  • Paint only, never content: editing, undo and what is submitted stay the engine's.
  • The next edit drops them unless a hook answers them again, so a stale tint cannot outlive its draft.
  • sec-default can strip them like the other $.prompt.* powers.

With that, the mod would be at parity with our other hosts.

poteat contributor · 19 days ago

@lperezmo

No single place to filter what the model reads

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.append which represents "appending something to the session".

@wkasekende

A mod cannot style part of the draft

This is a sensible ask, it sounds like a marks system. We'll queue this up.

lynxnathan · 19 days ago

Surely we will do better this time. https://en.wikipedia.org/wiki/Browser_wars

richardkmichael · 19 days ago

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 to diffm.)

Requests
  1. A way to return the keyboard to a mod's pane after $.prompt.fill.
  2. A ui.selection event, at least for selections inside the mod's own pane.
  3. A $.prompt.attach method that adds composer pills carrying context.
  4. The native sidebar's close mark for mod panes, out of the focus ring.
  5. A method that registers a mod's own keybinding actions.
  6. Access to the spinner tip line, and a method that adds tips at runtime.

Details and speculative usage from Claude:

1. A mod cannot get keyboard focus back after $.prompt.fill
Button pressed in a focused pane
  $.prompt.fill                 edits the prompt draft
    useReleaseOnEdit            calls focusPane(null) on the first change
  $.ui.focus                    denied: "that site does not hold the keyboard"
  $.ui.open({ focus: true })    honoured only while the prompt draft is empty

Only a fill from a Client region keeps focus, which means routing every key through a region and giving up the focus ring, hotkeys and Button styling.

Request

A $.ui method that returns the keyboard to the mod's pane, or a $.prompt.fill option that leaves focus where it is.

// Type `a` in the pane on a commit in the list: add the commit ref but keep the keyboard in the pane
await $.prompt.fill({ text: `${commitReference} `, mode: 'append', keepFocus: true })
2. A mod cannot observe a text selection

The native /diff sidebar 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. A Client region 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
  1. Selection inside the mod's own pane: a ui.selection event 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))
``

  1. Optionally, selection anywhere: the same event for the transcript and other panes. It shows a mod what the person selects outside its pane, though a mod can already read the whole transcript with $.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), and prompt.attachment only rewrites attachments the engine created. The native /diff sidebar, by contrast, never edits the prompt draft:

drag ends
  setIdeSelection({ source: "diff", … })    stores the selection in engine state
    composer leadingPill                     draws the pill
    submit                                   sends a selected_lines_in_diff attachment
Request

A $.prompt.attach method that adds labelled pills carrying context, several at once, cleared on submit or deletion, without editing the prompt draft or moving focus.

// replaces the commit reference, its reconciliation on each edit and its removal on submit
await $.prompt.attach({
  key: commit.sha,
  label: `⧉ ${commit.short}`,
  context: askTextOf(commit, files),
})
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.focus hook 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.open option for it.

await $.ui.open({ id: Names.PANE_ID, title: Names.PANE_TITLE, focus: true, closeMark: 'native' })
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 action borrows the native diff sidebar's app:diffFileListUp and app:diffFileListDown, and with both panes open the engine gives the chords to whichever was drawn last. A Button otherwise takes only a one-character hotkey; 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 Pane context, for Buttons to name.

await $.keybinding.register({ action: 'commits:listUp', context: 'Pane', keys: ['ctrl+up', 'k'] })
<Button key="list-up" action="commits:listUp" onPress={() => actions.selectBy(-1)} />
6. A mod cannot see or add to the spinner tip

The spinner row is a Spinner component: a hook can rewrite its word, message and suffix, or draw its own tree. The tip line under it is separate. It is drawn from the app state spinnerTip, so a mod cannot read, replace or hide it, and no event marks a tip shown. The spinnerTipsOverride.tipsFile setting is honoured only from user, --settings and 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 tips plugin, behind tengu_tips_mod, picks a tip each turn and shows it through a host interface that installed mods do not get.

Requests
  1. The tip line as a render site: a tip prop on Spinner, or a SpinnerTip component, that a hook can read, rewrite or hide. A hook could then draw anything there, not only tips.
  2. A method that adds a tip to the rotation at runtime, taking the setting's entry shape.

``ts
// after seeing the model poll with sleep
$.ui.tip({ id: 'loop', text: '/loop 5m <check> replaces polling with sleep' })
``

  1. Optionally, an event when a tip is shown.
rezzminator · 18 days ago

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.call context. It picks its own milestone to compact at, and gives a focus that becomes the summarizer's instructions. 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:

  1. Set CLAUDE_CODE_AUTO_COMPACT_WINDOW low for the whole process. It isn't a policy. It only sets the point past which core asks session.compact (auto) before every request.
  2. Answer { skip } to every ask until the party reaches its own point, or until its model asked to compact.
  3. When the model asks, wait for core's next automatic ask, and let it through with the model's focus as instructions.

It works, with two costs:

  • A model that asks while it is still below that point has to wait until it gets there.
  • If the mod fails to load, the process-wide override is left on its own, and the main session silently compacts at the subagents' point.

The ask:

// Compact that loop before its next model request; resolves { skip } or the compaction.
await $.session.compact({ agentId, instructions })   // agentId absent: the main conversation

It would run through session.compact with trigger: 'plugin' and e.agentId set, as it does today. Called from a tool.call or turn.step hook, 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.register never reaches a subagent whose definition has a tools: 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 in turn.step. A registration flag that offers a plugin tool to every loop, or an agent.spawn field that adds tools, would close that side too.

furqan-khan07 · 18 days ago

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

nogu66 · 17 days ago

ui.render hooks 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 pane by and never calls invalidate. With by on 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 every tool.call makes 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 props and viewport. 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 say ui.render fires "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.json is { "name": "inval-src", "version": "0.0.1" }, or inval-by for the other. hooks/hooks.json is { "modules": ["./register.tsx"] }. The modules:

src/hooks/register.tsx

/* @jsx h */
import type { Register } from 'claude-code'

let renders = 0

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    const r = await next(e)
    await $.command.register({ name: 'srcopen', description: 'open pane src', immediate: true })
    await $.command.register({ name: 'srcnoop', description: 'control, no invalidate', immediate: true })
    await $.command.register({ name: 'srctimer', description: 'three invalidates from a clock', immediate: true })
    return r
  })
  on('command.run', { command: 'srcopen' }, async $ => {
    await $.ui.open({ id: 'src', title: 'src', rows: 3 })
    return { text: 'src open' }
  })
  on('command.run', { command: 'srcnoop' }, async $ => {
    $.ui.log('REPRO src noop, no invalidate', { to: 'debug' })
    return { text: 'noop' }
  })
  on('command.run', { command: 'srctimer' }, async $ => {
    let n = 0
    const t = $.clock.every(2000, () => {
      n++
      $.ui.log(`REPRO src invalidate #${n}`, { to: 'debug' })
      $.ui.invalidate('ui.render')
      if (n >= 3) t.cancel()
    })
    return { text: 'timer armed' }
  })
  on('ui.render', { component: 'Pane', requestId: 'src' }, async ($, e) => {
    renders++
    $.ui.log(`REPRO render src #${renders} props=${JSON.stringify(e.props)} viewport=${JSON.stringify(e.viewport)}`, { to: 'debug' })
    const { Text } = $.ui.resolve(e)
    return <Text>{`src renders=${renders}`}</Text>
  })
}

by/hooks/register.tsx

/* @jsx h */
import type { Register } from 'claude-code'

let renders = 0

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    const r = await next(e)
    await $.command.register({ name: 'byopen', description: 'open pane by', immediate: true })
    return r
  })
  on('command.run', { command: 'byopen' }, async $ => {
    await $.ui.open({ id: 'by', title: 'by', rows: 3 })
    return { text: 'by open' }
  })
  on('ui.render', { component: 'Pane', requestId: 'by' }, async ($, e) => {
    renders++
    $.ui.log(`REPRO render by #${renders} props=${JSON.stringify(e.props)} viewport=${JSON.stringify(e.viewport)}`, { to: 'debug' })
    const { Text } = $.ui.resolve(e)
    return <Text>{`by renders=${renders}`}</Text>
  })
}

Steps. 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.

  1. CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./src --plugin-dir ./by --debug-file debug.log
  2. /srcopen, then /byopen. by is now on screen and src is a tab.
  3. /srcnoop twice as a control, then /srctimer, and wait about 8 s.
  4. grep REPRO debug.log

I 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.

11:02:55.020 ui.open inval-src src (asked, 200 columns): placed
11:02:55.050 [inval-src] REPRO render src #1 props={bodyColumns:89, placement:dock, scroll:{bodyRows:44}} viewport={columns:110, rows:50}
11:02:55.130 [inval-src] REPRO render src #2 props={bodyColumns:89, placement:dock, scroll:{bodyRows:44}} viewport={columns:110, rows:50}
11:02:59.390 ui.open inval-by by (asked, 200 columns): placed
11:02:59.408 [inval-by]  REPRO render by #1 props={bodyColumns:89, placement:dock, scroll:{bodyRows:43}} viewport={columns:110, rows:50}
             # 5 s idle, no renders
11:03:09.037 [inval-src] REPRO src noop, no invalidate
11:03:12.958 [inval-src] REPRO src noop, no invalidate
11:03:19.062 [inval-src] REPRO src invalidate #1
11:03:19.068 [inval-by]  REPRO render by #2   # props and viewport same as #1
11:03:21.064 [inval-src] REPRO src invalidate #2
11:03:21.067 [inval-by]  REPRO render by #3   # same
11:03:23.068 [inval-src] REPRO src invalidate #3
11:03:23.075 [inval-by]  REPRO render by #4   # same

Other things I saw:

  • Only the pane on screen is redrawn. A pane that is only a tab is not. With the opens swapped so src was on screen, by rendered twice at open, 88 ms apart, and src once. The three invalidates then redrew src each time and never by.
  • Under claude plugin test, a plugin's invalidate re-ran every mounted Pane, including one drawn only by the test's own hook and one mounted under another plugin's name. It did not re-run a mounted TurnDuration, which the plugin doesn't hook. So the scope looks like every instance of a component the plugin hooks, with requestId ignored.
  • I only tested the terminal with docked panes. Inline placement and the other surfaces are untested.

</details>

seosy218-ops · 16 days ago

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):

  • Every desktop engine process logs session.start surface=null interactive=false, and no ui.render arrives, with components hooked at AbovePrompt, SessionMode, Pane and under AssistantMessage. The same mod draws fine in the terminal.
  • The mod's slash command runs (the command is logged), but the UI answers "/<name> isn't a command here" and no pane opens.
  • The desktop's app.asar has no ui_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 AbovePrompt draws in the desktop, the overlay can become a mod.

gmetzker-4c · 16 days ago

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:

// on PromptEditInput
selection?: { text: string }   // absent when nothing is selected

// or a read call
$.ui.selection(): Promise<{ text: string } | null>

With that, the mod is a few lines:

on('prompt.edit', ($, e, next) =>
  e.key?.key === '>' && e.selection
    ? insertQuote(e, e.selection.text)
    : next(e))

Nice to have:

  • The selection's message id and screen rows, so a mod can open a reply box under the highlight.
  • Mod-registered keybinding actions (also asked for by @richardkmichael above), so the trigger doesn't have to be a printable key.

Related: #67427 (the same workflow in VS Code, closed as stale) and #95144 (a Reply shortcut in Desktop).

iritbrener-blip · 15 days ago

Ask: when a plugin ships modules and function hooks are off, say so.

Today a mod-only plugin installed without CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 loads 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 like my-plugin: hooks module skipped, function hooks are disabled at 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.json accepts modules and hooks side 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.

my-plugin/
├── .claude-plugin/plugin.json
└── hooks/
    ├── hooks.json
    ├── my-mod.tsx
    └── check-mods-enabled.sh    # chmod +x

hooks/hooks.json:

{
  "modules": ["./my-mod.tsx"],
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/check-mods-enabled.sh\""
          }
        ]
      }
    ]
  }
}

hooks/check-mods-enabled.sh:

#!/usr/bin/env bash
# SessionStart hook. Reads CLAUDE_CODE_ENABLE_FUNCTION_HOOKS.
# Set to 1: prints nothing. Otherwise: prints hook JSON carrying the same warning
# as systemMessage (user) and additionalContext (model). Always exits 0.
set -euo pipefail

[ "${CLAUDE_CODE_ENABLE_FUNCTION_HOOKS:-}" = "1" ] && exit 0

MESSAGE='my-plugin: mods are off, so it is inactive. Add \"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS\": \"1\" to the env block of ~/.claude/settings.json and restart Claude Code.'

printf '{"systemMessage": "%s", "hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "%s"}}\n' \
    "$MESSAGE" "$MESSAGE"

Details that matter:

  • Both channels. systemMessage is the line the person sees. additionalContext tells the model, so when someone asks "why didn't the guard fire?" it answers from the session instead of guessing.
  • Silent when on. With the flag set the script prints nothing, so the check costs a mod user nothing. It always exits 0, so it can never block a session.
  • ${VAR:-} under set -u. An unset flag is the case being detected, so a bare $CLAUDE_CODE_ENABLE_FUNCTION_HOOKS would abort the script instead of warning.
  • Quoted ${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.json env (nothing exported from the shell profile): claude plugin validate accepts the combined hooks.json; flag removed → the warning shows and reaches the model; flag restored → no output. So the settings.json env value does reach classic hooks' environment, which the silent-when-on half depends on.

dkokotov · 15 days ago

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.render and 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!

poteat contributor · 13 days ago

We've launched! Now the real work begins.

@seosy218-ops

Desktop

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

2. The first pane opened in a session renders twice with the same inputs.

I believe we've fixed that in v285 - it was a reactive rendering issue related to dynamic width.

@dkokotov

Ask: make this work in cloud sessions.

I'm also a big fan of our cloud support and it's within our scope to get this working asap.

shivam-dhaka · 13 days ago

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

  1. A Client surface module never loads on Desktop. Pane shows Client 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 has Client frame torn down. Same module loads and takes pointer events fine in the terminal. Tried it as .ts and as plain .js with no imports and a trivial top level, same result.
  2. When that happens the mod isn't told, so it can't fall back to another view on its own. I had to hardcode "no Client on Desktop".
  3. One Code element with format: '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.
  4. Right-aligned content in a pane draws under the pane's close button. I'm padding the right edge by hand.
  5. Still seeing what @seosy218-ops reported: a timer I start under if (e.isInteractive) in session.start runs in the terminal and never on Desktop. Starting it from a command.run hook works, so I'm guessing Desktop still reports interactive=false. Didn't confirm the value.
  6. Related to @lperezmo's hot-reload note, different load path: with a plugin installed from a local marketplace, Desktop kept drawing the install-time copy after I edited the source. /reload-plugins alone didn't pick it up, needed uninstall + install every time.

Essentials I missed

  1. +1 to @AlmogBaku on 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.
  2. Code on 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 an Svg, which loses syntax colours and can't be clicked.
  3. Only Button and Markdown links are pressable, and a Button label is plain text. A list row can't be one clickable thing with a chip or a dim part inside it.
  4. No way to see how a pane paints on Desktop while building. claude plugin test checks 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 Client one if that helps.

seosy218-ops · 13 days ago

Built a phrase bar as a mod (one row of buttons in AbovePrompt that send common prompts like continue or /compact). Desktop 2.19675 bundling 2.1.286, Windows 11.

Bug: $.command.run({ command: 'compact' }) shows no compacting indicator on Desktop

When you type /compact in 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:

import type { Register } from 'claude-code'

export const register: Register = on => {
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    if (e.props.hasSurvey) return next(e)
    const { Button } = $.ui.resolve(e)
    return <Button key="compact" label="/compact" onPress={() => $.command.run({ command: 'compact' })} />
  })
}

Expected: clicking it looks the same as typing /compact and pressing Enter. Actual: no indicator until compaction finishes.

Workaround on our side: the button shows /compact … until $.command.run resolves and while a session.compact hook 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.

seosy218-ops · 13 days ago

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 Input mid-composition breaks IME input on Desktop

If the tree holding an Input gets redrawn while an input method is composing a word, the composition is cancelled. With Microsoft Pinyin, typing nihao puts the letters nihao into the field. The candidate window either never shows up or closes right away. Pasting Chinese works. The usual way to write a form, where onInput writes $.state and 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.

import { atom, read, update } from 'claude-code'
import type { Register } from 'claude-code'

const typed = atom({ plugin: 'ime-repro', key: 'typed' } as const, '')

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    await $.command.register({ name: 'ime-repro', description: 'IME repro' })
    return next(e)
  })
  on('command.run', { command: 'ime-repro' }, async $ => {
    await $.ui.open({ id: 'ime-repro', title: 'IME repro' })
    return { text: 'opened' }
  })
  on('ui.render', { component: 'Pane', requestId: 'ime-repro' }, async ($, e) => {
    const { Box, Input, Text } = $.ui.resolve(e)
    const value = await read($, typed)
    return (
      <Box flexDirection="column" gap={1}>
        <Text>works: onInput does not redraw</Text>
        <Input key="ok" onInput={() => {}} onSubmit={() => {}} />
        <Text>breaks: onInput writes state this tree reads</Text>
        <Input key="bad" onInput={v => update($, typed, () => v)} onSubmit={() => {}} />
        <Text dimColor>{value}</Text>
      </Box>
    )
  })
}

What we narrowed it down to: value alone is fine, and onInput alone 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.

Und3rf10w · 12 days ago

Plugin tool.call hook breaks worktree isolation for isolated subagents

Version: 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.call hook whose matcher matches a tool call, that call runs without the isolated agent's working-directory override. This happens with both the Agent tool's isolation: "worktree" and a workflow's agent({ isolation: 'worktree' }).

The effects:

  • Bash refuses every command: "The working-directory isolation context for this agent was lost, so this command would run in the parent session's directory instead of this agent's worktree (…). Refusing to run it."
  • Read, Glob and Grep silently resolve relative paths, or a missing path, against the parent session's checkout rather than the worktree.
  • EnterWorktree, when the agent calls it, takes the main-session code path. It changes the process working directory and sets the parent session's cwd to the agent's worktree.

Built-in plugins' tool.call hooks, which run on the main thread, are not affected.

Steps to reproduce

  1. Create a plugin with 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))
}
``

  1. In any git repository, run 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."
``

  1. For a control, replace the hook with on('session.start', ($, e, next) => next(e)) and repeat.

Expected Result

The agent's pwd prints <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.call with a literal matcher for an unrelated tool, { tool: 'mcp__<plugin>__noop' } | -p | runs in the worktree |
| broad tool.call, launched with CLAUDE_CODE_HOOKS_SAME_THREAD=1 | -p | runs in the worktree |

With --debug, the failing run logs [worktree] blocked shell exec after cwd-override loss while the hook's tool.call dispatch 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.

  • An isolated agent's working directory is held in an AsyncLocalStorage store that wraps the agent's run.
  • A worker-hosted hook's 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 the tool.call chain, including the tool's own execution, runs outside the agent's cwd store.
  • The in-flight dispatch record does capture the agent's cwd, but only the plugin's $ operations use it.
  • Downstream effects:
  • Bash's guard sees an agent worktree with no active cwd store, and refuses.
  • EnterWorktree and ExitWorktree decide whether they're in a subagent from that store alone. With the store missing, they take the main-session branch.
  • Read, Glob and Grep fall back to the session cwd.
  • In-process (native) hooks call next directly, so they keep the context.
  • A literal matcher that doesn't match the call is tested on the main thread, and the call never hops to the worker. A RegExp matcher always hops.
  • claude plugin test runs hooks modules on the same thread, so plugin tests can't catch this.

Suggested fix

  1. Bind the stored next continuation to the dispatcher's async context when the call is handed to the worker, for example AsyncResource.bind. Resume streaming hops inside that resource's scope too.
  2. Have EnterWorktree and ExitWorktree decide "subagent-ness" from the tool-use context (agent id / agent worktree), not from the async store alone.
  3. Have Read, Glob and Grep resolve against the tool-use context's session cwd, as Bash does, so a lost store can't silently redirect them.

Workarounds

  • Give every plugin tool.call hook a literal tool matcher that leaves out the cwd-sensitive tools: Bash, PowerShell, Monitor, Read, Glob, Grep, Edit, Write, NotebookEdit, EnterWorktree, ExitWorktree and Agent.
  • Alternatively; answer or deny without calling next().
  • Calling $.tool.call from the hook doesn't help: it runs in the main session's context.
  • Users can launch with CLAUDE_CODE_HOOKS_SAME_THREAD=1. That variable looks internal, so it's not a supported fix.
simon-bauer-sonarsource · 12 days ago

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 off
  • no N mods active line in /plugin
  • ~/.claude.json → cachedGrowthBookFeatures.tengu_plugin_hooks_modules: false

The client default is true, but the unauthenticated GrowthBook eval returns false (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?

tzafrir · 12 days ago

Feedback: three gaps I hit with background agents on 2.1.287.

  1. A named agent (teammate) can't be tied back to its Agent call. agent.spawn doesn't fire for it, and the Agent result (status: "teammate_spawned") carries agent_id: "<name>@session-…", while its loop's tool.call and turn.complete carry agentId: "a<name>-<hash>". The only bridge I found is matching $.agent.list()'s name to the call's name. Could the result, or agent.spawn, carry the loop's agentId?
  2. $.agent.list()'s description is the first ~40 characters of the prompt, not the Agent call's description. For anything that displays agents, the short description is what people recognize.
  3. Teammates stay status: "running" in $.agent.list() after they finish and go idle, so the list can't tell working from idle. SubagentStop and turn.complete do 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 description from 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" />

scasella · 12 days ago

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" />

claude plugin marketplace add scasella/claude-flightdeck
claude plugin install flightdeck@claude-flightdeck

Things I hit on 2.1.287 (macOS terminal; desktop, VS Code and mobile only through $.ui.mount in the test kit), roughly by how much code they cost:

  1. Server tools never reach tool.call or turn.step. The built-in advisor isn't in TurnStepResult.toolUses and fires no tool.call; the only trace is a server_tool_use block and an advisor_tool_result (content advisor_redacted_result) in session.append rows. Could server tool uses show up in toolUses, or get an event with name, id, start and end?
  2. tool.check has no agentId. Logged at runtime, a background agent's Bash check arrives with keys [input, tool, tool_use_id] only, while the tool.call around it carries the agentId. To tell a subagent's check from the main loop's, I map tool_use_id → agentId from tool.call, in module scope, so it's lost on reload. An agentId on ToolCheckInput would remove that.
  3. Nothing says who allowed an ask. ToolCheckResult.rule covers settings rules and classic.PermissionDenied covers refusals, but in auto mode most checks are ask and I didn't find anything reporting that the classifier (or the person) allowed one. I infer it from the call's result having no deny. A decidedBy on the outcome, or a tool.decided event, would make it exact.
  4. A background agent's turn.complete has an empty answer (logged: answerLen=0). Its report travels as a SubagentHandback tool call (tool.call with the agent's agentId and a message), 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 the turn.complete docs pointing to SubagentHandback, or the answer on that turn.complete, would save the next person the search.
  5. The permission mode isn't on tool events. Logged at runtime, classic.PreToolUse in a hooks module carries only the call's envelope (command, description, tool, tool_use_id for Bash): no permission_mode or agent_id, though classic.UserPromptSubmit has permission_mode. So a mid-turn shift+tab goes unseen until the next prompt. A mode on tool.check/tool.call, or a $.session getter, would fix it.
  6. Unlisted loop ids can't be told apart. Per the d.ts, workflow agents, compaction and memory forks all reach turn.step with agentIds $.agent.list() doesn't name. A kind on AgentLoop would let a pane label them.
  7. Test kit: $.tool.call mints a fresh tool_use_id and raises no tool.check, and it drops a passed agentId (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.
  8. Smaller: colour props take "a theme key or a raw color", but the d.ts lists no keys; I pulled claude, suggestion, permission, success and others from the binary. A ThemeColor union would make light themes easy to get right.

What worked well: Client surface modules with their own every frame clock. The animated connectors and live clocks redraw only their own region, which let me drop a 400 ms full-pane redraw. And claude plugin validate printing exactly what a module hooks and calls made the README's footprint section easy to write.

poteat contributor · 12 days ago

@Und3rf10w

worktree isolation re tool.call

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!

Helban · 12 days ago

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.render hook on AssistantMessage fires for the block that opens a reply (every block my hook logged had isFirstOfReply: true) and not for the dim narration Claude Code prints between tool calls, the rows tagged · summary. A catch-all ui.render hook logging e.component over 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 as AssistantMessage with a prop that names them (isInterstitial, or kind: '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 context on a rewritten e shipped 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.

Und3rf10w · 12 days ago

@poteat

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.

Thank you very much! I can confirm this is resolved in 2.1.288 for all of the cases I tested.

xuanji86 · 12 days ago

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.step usage, 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:

  1. No way to switch the main view to an agent. SiteView.agentId is 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 as footer:openSelected, would let a pane hand off to the native view.
  2. One long text blanks the whole site. A text over 10,000 characters (or a tree over 100,000) gets the whole tree refused and the engine draws its own. One 30k-character Bash command from a subagent emptied the pane, back button included. Cutting the text, as Code falls back to plain, would fail more softly; for now every string has to be budgeted.
  3. Hotkeys need the keyboard, and the keyboard needs ctrl+x tab. In terminals that don't pass ctrl+x chords (iTerm2 here), the only ways to hand a pane the keys are a click or ui.open({ focus }). A bindable action that focuses a given pane would help.
  4. Validator false positive. A destructured option named like a top-level function that takes $ (showList($, { unfold = false }) next to async function unfold($)) fails with "the function handed $ "unfold" is declared more than once".
  5. Dock width. 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.

secondarykey · 12 days ago

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. $.fs is "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.run and 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: $.fs is 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 validate already 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.)

Helban · 11 days ago

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 the isError variant), prompt.attachment and session.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.

  1. ~/.claude/history.jsonl keeps the prompt as typed, so up-arrow shows the real name. I found no event for it in the 2.1.288 types.
  2. A prompt typed while a turn is running lands in the transcript's queue-operation entries before prompt.submit rewrites it. I counted 7 occurrences there, while the user rows for the same prompts held the marker. reference.md says the opposite: "a plugin that must change a prompt's words everywhere rewrites them at prompt.submit, before they are queued."
  3. In claude plugin test, $.session.append(...) fails with no implementation for session.append, even with an on('session.append') hook registered beneath it, so I couldn't test a session.append rewrite in the kit.

Could the history entry and the queue record go through the prompt.submit rewrite, or get an event of their own? If the queue record is meant to stay as typed, one line in reference.md would save the next person a measurement.

gbrussich52 · 11 days ago

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:

  • An empty deny now blocks the call.
  • Wrong-shape returns and throws are skipped, and the "hook skipped" line reaches the model. That makes failures much easier to see.
  • A .catch that returns { deny } fails closed, 3 of 3 runs.
  • tool.check deny 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 deny returned after await 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 the git push --force example:

A deny stops a call only if your hook returns it without calling next. Once next(e) resolves, the tool has already run. A deny returned after that changes what Claude is told, not what happened. To block a call, decide before you call next.

And one sentence for the retry paragraph:

Each call to next(e) runs the tool again.

A debug-log line when a hook returns deny after next has resolved would also help, because today that path is silent.

Thanks again.

42tahara · 11 days ago

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 test with claude-code/testing runs the tests against the engine itself, so there is nothing to import and nothing to replicate.

The one behavior I said a stubbed next could 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-side on('tool.call') standing in for the tool:

test('a hook that throws is skipped and the tool runs anyway (fail-open)', async ($, on) => {
  const ran: string[] = []
  on('tool.call', ($: any, e: any) => {
    ran.push(e.command)
    return { result: { stdout: 'ran', stderr: '' } }
  })
  await $.tool.call({ tool: 'Bash', command: 'echo THROW' })
  expect(ran).toEqual(['echo THROW'])
})

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:

the engine reported:
  fold-probe's tool.call hook was skipped: fold-probe: probe boom

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 validate pointer back then. I use it as the static half, and plugin test covers 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.

3dnow · 9 days ago

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 /weektoken pane (https://github.com/3dnow/claude-mods).

  1. An interactive Svg in the band reloads when another site of the same plugin redraws. Pressing any Button in our Pane made the band's progress bar (an isInteractive Svg) flash. A trace showed the AbovePrompt ui.render hook 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: height only, markup with width="100%", no viewBox and percentage geometry. That stretches with the slot but stops at the image default of about 300px.
  1. On desktop, the first click on a Pane that doesn't hold the keyboard raises only ui.focus. No ui.press follows, so the button needs a second click. We run the button's action ourselves when ui.focus lands on it with no press within about 300 ms, but only if the pane's last-rendered e.props.isFocused was false, so Tab and arrow walks don't press anything. Inside the ui.focus hook, $.ui.panes() already reports isFocused: true even 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.
  1. $.process.run resolves argv[0] with init.env.PATH. We rely on it to find claude, because processes started from the desktop app often get a short PATH (measured: /usr/bin:/bin alone can't find it). Worth stating in the docs.
  1. Directory validation: three findings that look like false positives.
  • The SVG namespace string http://www.w3.org/2000/svg in code counts as "can send data off the machine", and is combined with a README mention of ~/.claude.json into MCP_FORWARDS_CREDENTIAL_ENV.
  • README files are scanned like executable surfaces. An author link to x.com plus a perl command line in a disclosure table produced a credential-forwarding hold, and a documentationUrl pointing at README.md turned the README's screenshots into an UNREAD_ASSET_REFERENCED hold.
  • types is reported as an unknown field, though the manifest reference documents it and claude plugin validate fails without it.
VanguardBit · 9 days ago

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.13 adds a mod on three events (README, hooks/register.ts). Compared with the PreToolUse/SessionStart hooks 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; SessionStart reports 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; SessionStart reports 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 root and provenance to plugin.register; the refusal row depends on them. We keep the hooks for claude -p and other harnesses.

Gaps, smallest first:

  1. skill.prompt carries no path, so we repeat Claude Code's skill lookup to find the directory. A dir field would let us drop that copy.
  2. Once a hook above rewrites the claudeMd text, instructionFiles turns 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.
  3. plugin.register fires for plugins that ship a hooks module. A mod has no way to refuse a skill-only plugin.
  4. provenance reads name@marketplace. With the marketplace commit SHA, a mod could confirm the installed tree is the commit the directory reviewed.
  5. #93426: Claude Code writes bookkeeping files into the plugin cache, and a tree-integrity check reports them as unlisted files.

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).

tzafrir · 9 days ago

Feedback: from building a mod, Human in the loop (Claude assigns the user tasks through registered tools; they wait in a pane until answered):

  1. turn.start carries no agentId, while turn.step and turn.complete do. 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 reading AbovePrompt's isWorking through a render hook that draws nothing. Could turn.start carry agentId, or could $ say whether a turn is running?
  2. Input is 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.

Heartdok · 5 days ago

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.json do not carry over, nor do enabledPlugins / 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, /plugin unavailable).

aik099 · 5 days ago

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@builtin seats itself outermost when the org is Team/Enterprise or the machine has managed settings, and moves every classic.* event past the user tier. Only --debug shows it:

cc-plugin-sec-default@builtin seated outermost: this machine has managed settings
mac-notify: classic.Notification bypassed by cc-plugin-sec-default (tier user); beneath runs

Seen on 2.1.289 through 2.1.295. Things that make it hard to deal with:

  • claude plugin validate and claude plugin test both pass. The mod loads and part of it never runs. For a notification mod built on classic.Notification that's all of it. For third-party mods it's whatever the author put in classic hooks (e.g. cache-tax seeds its state from classic.SessionStart and resets on /clear there).
  • Authors on personal plans never hit it, so mods get published half-working for Team users without anyone knowing. The mod docs list classic events as usable with no mention of this.
  • It's seated by plan type, even when the org has no managed hooks or policy to protect.
  • Getting one reviewed plugin past it means creating managed settings with sudo (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:

  1. A visible notice when a loaded module hooks classic.* events that will be skipped, and a warning from claude plugin validate.
  2. A line in the mod docs next to classic events.
  3. A per-plugin allow in sec-default's options (next to allowManagedModsOnly), or only seating it when the org actually has something to protect.
rbartoli · 5 days ago

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:

  1. agent.spawn doesn't fire when SendMessage resumes a finished subagent. Only classic.SubagentStart fires, which can't refuse. A resumed run costs like a new one, so today a mod has to catch it in tool.call on SendMessage and work out whether the target is a finished subagent. Could agent.spawn fire for resumes too, with a resume flag?
  1. /subtask forks skip agent.spawn. classic.SubagentStart fires with agent_type: "fork", so a mod can count them but not hold them, while model-started forks go through agent.spawn with fork: true. Could user-started forks go through the same event?
  1. +1 to @frsorrentino's item 7, a compare-and-set on $.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

wkasekende · 5 days ago

Following up on my comment above: thank you for decorations in 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. dimColor on a run resolves to the theme's colour for dim text, so with an ANSI theme it comes out as ESC[37m, the terminal's white, which is the same as the text around it. A faint option (SGR 2, as Ink's dimColor emits elsewhere), or dimColor meaning 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. A cursor on $.prompt.fill (where the caret goes in the filled text, as a prompt.edit answer can already say) would let a mod repaint without moving the person's caret.

Happy to test any of these on a pre-release.

wkasekende · 5 days ago

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:

  1. Virtual text on a decoration, drawn but never part of the draft: the caret, the editing, submit and history never see it. It may hold line breaks, and it takes runs of its own like any decoration (a dim body, a coloured glyph).
  2. Placed by the rendered draft, not by the mod. The note goes on the row after the one holding the span's end, its ↳ 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).
  3. Painted without a key. A note often arrives on a pause, and it follows the caret (shown while the caret is on its span). A paint-only call, $.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).
  4. A character shown as another (an overlay over a range, the text unchanged): OpenCues spins the _ 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.

paragpandyareal · 4 days ago

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:

  • The 4 MB file read limit means bigger Word, Excel or PDF files need a fallback.
  • Pictures only draw in kitty and Ghostty, so everywhere else shows a card instead.
  • I wanted to put the comment box right under the line being commented on, but drawing a text input inside a custom view isn't possible today, so it sits below the document. Or at least I couldnt crack it.

Love mods as I can make what I need in Claude Code. It made something possible that I couldn't have done otherwise.

MohammedSaud404 · 4 days ago

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. A dir prop would remove all of that; details in #99956.

https://github.com/MohammedSaud404/rtl-terminal
/plugin install rtl-terminal --marketplace MohammedSaud404/rtl-terminal

wkasekende · 4 days ago

Two 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). A prompt.changed event (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.

dave-net-positive · 3 days ago

One for the list, from shipping a mod through an organization marketplace: the claude.ai Sync from GitHub step strips the top-level types key from plugin.json ("unrecognized key ... stripped — the SDK ignores unknown top-level fields"). That key is where the engine finds a mod's PluginState contract, so the synced copy fails the hooks-module validator on every $.state key and never loads, while the same plugin installed from the CLI or via managed extraKnownMarketplaces is fine. Details, repro and the workaround (module-scope state plus $.ui.invalidate) in #101258.

cheliosy · 1 day ago

Feature request (2.1.293): let a turn.step hook name the model that really served the response, for /workflows and agent rows

Context. Our mod routes some subagents' turns to non-Anthropic models: turn.step answers without next, so the step never reaches the Anthropic API. The stop chunk's TurnUsage can carry model, but the UI ignores it:

  • /workflows row: it shows fallbackModel ?? model. model is the Claude id resolved at spawn. fallbackModel is taken from message.model. For a response a hook made up, message.model is the engine's pre-hook step model, never the stop's usage.model. Every routed workflow agent therefore reads "Sonnet 5.5" (the spawn model) while a different model serves it.
  • Agents list / task detail: the task's model is the Claude id resolved at spawn, and nothing a hook returns changes it.
  • Workflow spawns: their content cannot be rewritten (agent.spawn), and no ui.render component covers the /workflows view or an agent row. So a mod has no field to show the real model there.
  • subagentStatusLine reaches only local_agent rows in the terminal footer. It does not reach /workflows, Desktop or Remote Control.

Ask (any one of these solves it):

  1. When a hook-made response's stop chunk states usage.model, copy it into message.model. The existing fallbackModel logic would then show the serving model in /workflows and the agents list with no new API. This feels like the smallest change.
  2. Or add an optional display field to the turn.step result (e.g. display: { model: string }) that the rows render instead of the resolved Claude id.
  3. Or let a ui.render component (or agent.spawn for 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.)

cheliosy · 1 day ago

Correction to my comment above. On 2.1.293, a mod can make a routed subagent show its model. $.agent.register accepts any non-empty model string. The spawn resolver keeps an unknown id, and both the agents list and /workflows print it verbatim, sanitised to [A-Za-z0-9._:/@\[\]-]. So registering the routed agent's definition with a display id such as deepseek-flash[B] works.

What remains, and what the request is about:

  • Model changes inside the run. If the hook moves the agent from one non-Anthropic model to another mid-run, nothing reaches message.model. The row keeps showing the spawn id. Honouring usage.model on a hook-made response (option 1 above) would cover this.
  • The unknown-id side effect. A definition with a non-Claude id risks any engine-side request made on the agent's model outside 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.
liby · 3 hours ago

A data point from a permission mod on 2.1.299, in auto mode. Its tool.check hook turns core's ask into allow when its own judge finds the call a reversible step of the user's task, and otherwise returns core's answer.

On SubagentHandback, that allow comes back as a denial, and the subagent's report never reaches the parent:

Only the auto-mode classifier can allow SubagentHandback: an allow of type 'hook' is not the classifier's verdict

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 AskUserQuestion and ExitPlanMode the engine already does the gentler thing and documents it: a hook's allow is ignored and the dialog still shows ("a hook only tightens" in the types; the permissions page, from 2.1.292). SubagentHandback is not named anywhere I could find.

Either of these would let a mod stay generic instead of hardcoding tool names:

  1. Treat a hook's allow on SubagentHandback as the engine treats it on AskUserQuestion: ignore it and run the classifier's review of the report, instead of denying the call.
  1. Say so on tool.check. Core's ask does carry reason: "SubagentHandback requires auto-mode classifier review.", but that is prose. A field next to ceiling on 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 an ask).

Until then the mod keeps a named set of those three tools and returns core's answer for them.

wkasekende · 2 hours ago

Thank you for virtualText, prompt.change and 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

  • virtualText with 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).
  • dimColor on a decoration emits real faint: ESC[2m ESC[37m alpha ESC[0m on an ANSI theme. That meets the faint-run ask from my earlier comment.
  • prompt.change origins I observed: Ctrl+C gives clear (empty text); Enter gives clear first (empty text), then the submit hook runs, and if the hook drops the prompt the draft comes back as engine with the caret at the end; the engine's undo gives undo; a plugin's own $.prompt.fill gives plugin with the plugin's name, so a mod can ignore its own fills.

Still open, with evidence

  1. cursor on $.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 by prompt.change reporting cursor=30 and $.prompt.read() reporting cursor=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. PromptFillArgs in the 2.1.300 types has no cursor.
  2. Run style under the caret. With the caret on the p of a dim alpha, the screen has ESC[2m ESC[37m al ESC[0m, then p with no style, then ESC[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.
  3. A paint-only call. There is no $.prompt.paint in 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.

wkasekende · 2 hours ago

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.

  • The limit is 1,048,576 bytes per module file. A file over it is not read: claude plugin validate reports cannot import "./c.mjs" (from hooks/register.ts): ... is over 1048576 bytes and was not read.
  • Splitting works. A mod of 1.4 MB over two 700 KB files validates, and loads and runs in a real session (a session.start hook 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.