When a mod does nothing and shows no error, the cause almost always falls into one of three groups: (1) it failed static analysis and the whole mod was not loaded, (2) it used something the hook runtime does not have and the hook was skipped, or (3) the tree it returned broke the component rules, so Claude Code drew its own UI instead. Start with claude plugin validate, then search the debug log for the mod's name.
Why mods fail silently
The official troubleshooting page opens with this: when a mod fails to load or a hook fails, Claude Code skips it and keeps the session going. A broken mod looks the same as a mod that does nothing.
That design keeps Claude Code itself from going down, but it leaves the author with few clues. So decide in advance where to look.
Quick reference
| Symptom | Where to look | Common causes |
|---|---|---|
| No command, no band, nothing | 1. Static analysis | Put $ in a variable, passed $ to a function in another file, dynamic import, an event not in this version |
| Loaded, but some hooks do not run | 2. Runtime | Used process or setTimeout, ran over 10 seconds |
| The band or panel shows the default UI | 3. Component rules | Passed undefined or false, missing a required field, a component not available on that surface |
First step: claude plugin validate
claude plugin validate .\my-mod
Without starting a session, this runs the same static analysis Claude Code runs at load time. It catches misspelled events, broken manifests, and unreadable modules.
- Passes but no
hooks:line appears:hooks/hooks.jsonhas nomodules, or it is misspelled - An event you registered is missing from
hooks:: Claude Code will not call that function either
1. It fails static analysis
At load time, the module source is analyzed, and if it fails, the whole plugin is not loaded.
| Code | Fix |
|---|---|
const s = $.store; s.get(k) |
Write $.store.get(k) in full |
const { store } = $ |
Do not destructure |
helper($) (where helper is in another file) |
You can pass $ only to top-level functions in the same file |
Passing $ to a function created inside a hook |
Move the function to the top level |
on(eventName, ...) (name in a variable) |
Write the event name as a string literal |
Declaring another variable named on inside register
|
Rename it |
await import('./x.mjs') |
Dynamic import is not allowed. Use a top-level import statement |
| Importing a file outside the plugin | Copy it into the plugin. The only outside import allowed is claude-code
|
require(...) |
Use ES module import
|
Three things are easy to miss:
-
The analysis also follows
on()calls in files you import. I had a leftover teston()in a shared helper, and the mod failed -
Registering the same event and matcher pair twice fails. When several features want
AbovePrompt, register once and dispatch inside - Registering an event that does not exist in that version prevents the whole mod from loading
That last one hits the desktop app in particular. The desktop app bundles its own Claude Code, which can be older than your terminal version. I had a mod that registered ui.fault (the event for Client component failures). It worked in the terminal but vanished entirely on the desktop (bundled 2.1.286). The official reference now says ui.fault requires v2.1.289 or later. When you use a new event, compare the version note in the reference with the version shown by /status on the desktop.
2. It uses something the hook runtime does not have
Hooks run in an environment that is neither Node nor a browser. The official API page also says there are no Node.js APIs and no timers like setTimeout.
-
Not available:
process,setTimeout,setInterval,Buffer, the DOM,require -
Available:
URL,TextEncoder/TextDecoder,AbortController,crypto(includingcrypto.subtle),atob/btoa,structuredClone,performance
| What you want | Use instead |
|---|---|
| Wait | $.clock.sleep(ms) |
| Run once later | $.clock.after(ms, fn) |
| Run periodically |
$.clock.every(ms, fn) (create it in session.start) |
| Current time | await $.clock.now() |
| Files, network, processes |
$.fs, $.http, $.process
|
In your own code, you just rewrite these. The trouble comes when you copy in an npm package: it reads process.env the moment it loads, throws, and the whole mod silently disappears. Putting local stand-ins at the top of that package's file, scoped to that file only, got it through.
const process = { env: {} }
const setTimeout = () => 0
Time limits
The other issue is time. These are the limits from the official reference that people hit most often.
| Thing | Limit |
|---|---|
A hook's own run time per call (waiting inside next and APIs does not count; $.clock.sleep does) |
10 seconds |
A .catch handler |
1 second |
All session.end hooks |
1.5 seconds combined (configurable) |
$.process.run |
30 seconds by default, 10 minutes max |
Hooks that run longer than 10 seconds are skipped. If you do heavy work in a guard that blocks tool calls (tool.check), the guard itself gets bypassed, so be careful.
3. The returned tree breaks the component rules
If the tree returned from ui.render breaks the rules, Claude Code draws its own UI without any error. Your band just disappears and the default comes back, which makes this the hardest one to notice.
| Code | Result |
|---|---|
Passing a field whose value is undefined
|
May be treated as invalid |
autoFocus: false, focus: false
|
Throws. Pass only true, or omit it |
plain: false on a Button
|
Cannot be drawn on the desktop. Use true or omit it |
Svg without source or alt
|
Not drawn (both are required) |
undefined in Client props |
Invalid (JSON values only) |
A component not available on that surface (Raster on the desktop, etc.) |
Invalid |
| Props a component does not accept (including typos) | Invalid |
Conditional fields naturally let undefined slip in, so I wrap the component factories in one place and strip those values.
// Do not pass fields that have no value to components
function strip(raw) {
const out = {}
for (const k of Object.keys(raw)) {
const make = raw[k]
out[k] = typeof make === 'function'
? (props = {}) => make(Object.fromEntries(Object.entries(props).filter(([, v]) => v !== undefined)))
: make
}
return out
}
const t = strip($.ui.resolve(e))
t.Button({ key: 'pdf', label: 'PDF', plain: true, dimColor: busy ? true : undefined, onPress })
Writing dimColor: busy ? true : undefined avoids ever passing false.
Find the one line that gives the reason
When a module fails to load, a hook is skipped, or a tree is rejected, Claude Code writes one line containing the mod's name. Where it goes depends on the session (official troubleshooting page).
| Session | Where the line appears |
|---|---|
Interactive session loaded with --plugin-dir
|
A faint line in the conversation |
| Normal session with a mod installed from a marketplace | Debug log only |
claude -p with --plugin-dir
|
Standard error |
In other words, when an installed mod is silent, no amount of looking at the conversation will show anything. Capture a debug log.
claude --debug-file .\mod-debug.log --plugin-dir .\my-mod
Watch the log in another window (PowerShell example).
Get-Content .\mod-debug.log -Wait | Select-String my-mod
These are the strings to look for:
| Log text | Meaning |
|---|---|
hooks module my-mod@inline loaded ... events: ... |
Loaded, with the list of registered events |
hooks module my-mod ... not loaded: |
Rejected by settings or policy. The reason follows the colon |
hooks module did not load: |
Top-level code threw. File and line are shown |
hook skipped: |
A hook threw, timed out, or returned the wrong shape |
ui.render (Pane) refused: / a hook returned a tree that does not validate
|
The tree broke the component rules. Reason included |
it crashed the hooks worker |
The mod was removed, for example because of a loop that never yields |
A line like ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own tells you exactly which prop on which component is wrong. Problems in group 3, component rules, are almost always solved in one look at this line.
To write your own markers, use $.ui.log('got here', { to: 'debug' }) to write to the log.
Getting a log from the desktop app
The desktop app cannot take launch flags, so the approach above does not work as is. How to capture a debug log from the desktop app is not described on the official pages, so it is not verified. My routine is to load the same mod in the terminal with --debug-file, find the reason line, fix it, and then check in the desktop app. However, things that fail only on the desktop, like an event missing from that version, do not reproduce in the terminal, so check the desktop version with /status first.
Can mods load at all?
Before blaming your mod, mods may be turned off by configuration. Run claude plugin test in a folder with no mod to see the state.
| Output | Meaning |
|---|---|
no hooks module to load |
Mods can load (there is just nothing to test in this folder) |
hooks modules are turned off here |
Turned off by disableAllHooks or an organization policy |
hooks modules are turned off in this process |
Turned off remotely by Anthropic |
Other causes of "nothing loads at all" include not having answered the trust prompt for a newly opened folder, and starting with --safe-mode or --bare.
Summary
- Run static analysis with
claude plugin validate - Check for
process,setTimeout, and the 10-second limit - Check for
undefined,false, required fields, and components not available on that surface - If still stuck, search the debug log for the mod's name
To see how other mods are written so they pass static analysis, read them in the modscode reviewed list. Every mod listed there is code that claude plugin validate can read.
This article was written with AI assistance (Claude) and checked against the official Claude Code docs; anything marked "not verified" could not be confirmed there.
Top comments (0)