Claude Code mod hook skipped and a paid Sume call still ran

A Claude Code mod hook that throws or times out is skipped, so the call runs anyway. Add .catch to fail closed, and know what a mod still cannot guarantee.

5 min readSume
All posts

A Claude Code mod hook that fails before it calls next is skipped, and the next handler runs in its place, so a guard that throws or times out lets the paid Sume call through. That is the documented default, not a bug in your code: Claude Code's events guide says a hook that fails "doesn't break the session" and that without a .catch handler the call would run. The fix is to attach .catch so a failed guard answers with a deny.

Details here come from Claude Code's events guide and organization guide, read 2026-10-03. Sume's paid tool names come from tools and gates.

How does Claude Code treat a failing hook?

Claude Code's docs give a short list of failure cases. The table collects them, including two that sit outside any single hook and that a .catch handler cannot rescue.

Hook failure behavior from Claude Code's docs, read 2026-10-03.
What happenedResult
The hook threw, timed out, or returned the wrong shape before calling nextClaude Code skips it and the next handler runs; the tool call proceeds
The hook failed after next resolvedThat result stands and nothing runs a second time
The hook has a .catch handler and failed before nextThe handler is called with the same event and can answer, for example with a deny
The thread running installed mods crashed three timesClaude Code unloads every mod that is not built in until /reload-plugins or a new session
A user starts Claude Code with --safe-modeInstalled mods do not run

What does a fail-closed guard look like?

This guard denies a paid generate call that carries no max_spend_usd, a field Sume enforces only when it is sent. The .catch handler runs only when guard throws or times out, and next.error.kind is throw or timeout. The handler has a shorter time limit of its own.

async function guard($, e, next) {
  if (!e.max_spend_usd) return { deny: 'Add max_spend_usd to this paid Sume call.' }
  return next(e)
}

export function register(on) {
  on('tool.call', { tool: /^mcp__sume__generate_/ }, guard)
    .catch(async ($, e, next) => {
      return { deny: 'Sume guard failed: ' + next.error.kind }
    })
}

Where do I see that a hook was skipped?

Claude Code logs one line naming the mod, the event, and the reason, such as my-mod: tool.call hook skipped: threw Error: boom. Where you read it depends on the kind of session; the events guide points to its troubleshooting page for the list. If you see that line next to a Sume call that should have been refused, the guard failed open. The hooks-not-firing post covers the other common cause, a matcher that does not match the MCP tool name.

If you are testing the guard, force a failure on purpose: make guard throw on a call that you know will arrive, then confirm in a throwaway session that the deny text comes back to Claude and that no job was created. Check jobs_list afterward rather than trusting the transcript alone. A guard that has never been seen failing closed has not been tested.

What is the time limit trap?

A hook that waits has a time limit, and a timed-out hook is skipped. Claude Code says time inside a mods API call such as $.ui.ask does not count against the limit, but time you spend awaiting a promise of your own does. So a guard that fetches a price quote over the network with its own await can time out on a slow day and, without .catch, let the call through. Keep guards synchronous where you can, as the one above is.

What can a mod never guarantee?

Even a correct fail-closed guard has limits. These are the ones to plan around, each tied to something Claude Code or Sume documents.

  • That it is loaded. A crash loop, --safe-mode, disableAllHooks, or a managed policy can all leave a session without it, and .catch cannot help a mod that is not running.
  • That it covers every client. A script or another editor using the same Sume key never meets it.
  • That it knows the price. Sume's spend gate is wallet and admission; dry_run=true previews it and max_spend_usd caps a call only when sent.
  • That it covers script_run's child calls, which run on the Sume side.

What should be the real limit?

Put the hard limit where the money is. With OAuth and Write off, Sume's paid tools are hidden and return insufficient_scope (OAuth and API keys); the wallet balance bounds what any session can reserve; and for unattended jobs, Agent Completions require a generation_spend_cap_usd on each call. Use the mod as a seat belt on top of those.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume