DEV Community

Haruki Nakada
Haruki Nakada

Posted on

Claude Code mod not loading or rendering with no error? Where to look

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
Enter fullscreen mode Exit fullscreen mode

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.json has no modules, 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 test on() 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 (including crypto.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
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Watch the log in another window (PowerShell example).

Get-Content .\mod-debug.log -Wait | Select-String my-mod
Enter fullscreen mode Exit fullscreen mode

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

  1. Run static analysis with claude plugin validate
  2. Check for process, setTimeout, and the 10-second limit
  3. Check for undefined, false, required fields, and components not available on that surface
  4. 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)