Skip to content

Use the mods API

Call the Claude Code mods API to add commands and tools, call a model, run timers, show status, message other sessions, and reach files, processes and the network.

Events decide when your mod runs; the mods API is what it can do once running. Every hook receives the API as $, organised into namespaces such as $.command, $.ui and $.fs. The hooks module itself has no Node.js APIs, no setTimeout, and no file or network access of its own, so everything outside your code goes through $.

Start with /docs/plugins/mods/create if you have not built a mod yet. The full method list is in /docs/plugins/mods/reference.

Commands and tools

Register both inside session.start. Claude Code waits for that hook before the first prompt, so anything registered there exists from turn one.

A command for the user

Register it, then answer command.run for its name. This /wip command shows uncommitted files, optionally filtered by a path:

on('session.start', async ($, e, next) => {
  await $.command.register({
    name: 'wip',
    description: 'Show uncommitted files',
    argumentHint: '[path]',
    immediate: true,          // allowed to run while Claude is mid-turn
  })
  return next(e)
})

on('command.run', { command: 'wip' }, async ($, e) => {
  const args = ['git', 'status', '--short']
  if (e.args) args.push('--', e.args)   // e.args is whatever followed the command, or ''
  const out = await $.process.run(args)
  return { text: out.stdout.trim() || 'Nothing uncommitted' }
})

/wip appears in the / list with its description, and [path] shows as a hint after you type /wip . The reply appears in the transcript after the plugin name, and Claude reads it too. Return {} to print nothing, for example for a command that only opens a pane.

Without immediate: true, a command typed during a turn waits until the turn ends.

Pick a name no built-in command uses (type / to see them). $.command.register throws for a taken name, for example "/focus" refused: it is the built-in /focus, and because a throwing hook is skipped, the rest of your session.start hook would not run. Register commands last, or wrap them in try/catch.

A tool for Claude

Give it a name, a description Claude reads, and a JSON Schema for the input. Claude sees it as mcp__<plugin>__<name>; handle it in a tool.call hook filtered on that full name. In a plugin called ops-mod:

on('session.start', async ($, e, next) => {
  await $.tool.register({
    name: 'service_health',
    description: 'Return the current health JSON for one of our services by name, e.g. "billing" or "auth"',
    inputSchema: {
      type: 'object',
      properties: { service: { type: 'string' } },
      required: ['service'],
    },
  })
  return next(e)
})

on('tool.call', { tool: 'mcp__ops-mod__service_health' }, async ($, e) => {
  const res = await $.http.fetch('https://status.internal.example/' + encodeURIComponent(e.service) + '/health')
  return { result: res.ok ? res.text : 'Health check failed with HTTP ' + res.status }
})

Always return a result, including on failure, so Claude knows what happened.

Tip: If MCP tool search defers your tool, Claude sees only its name until it searches. Add isDeferred: false to the registration (v2.1.293+, ignored by older versions) to load it upfront every turn.

Calling a model

$.model.complete sends one prompt, with no conversation history, using the session's credentials. Good for small classification or summarising jobs:

on('command.run', { command: 'commit-type' }, async ($, e) => {
  const r = await $.model.complete({
    model: 'haiku',
    system: 'Classify the change. Reply with exactly one of: feat, fix, chore, docs, refactor.',
    prompt: e.args,
    maxTokens: 10,
    timeoutMs: 10000,
  })
  return { text: r.isAnswered ? 'Suggested type: ' + r.text.trim() : 'No answer (' + r.reason + ')' }
})

API failures do not reject: check r.isAnswered, and read r.reason when it is false. The call does reject for requests Claude Code refuses to send, such as a model your organisation blocks. maxTokens defaults to 1024. Other options (such as effort) are in your generated types.

$.model.fork({ prompt }) asks one question over the current conversation, with the same model and system prompt, so most of it comes from the prompt cache. Both calls spend the user's plan or API key.

Background work

A hook handles one event and has a time limit. Anything longer lives on a timer started from session.start:

CallReplaces
$.clock.every(ms, fn)setInterval
$.clock.after(ms, fn)setTimeout
await $.clock.now()Date.now()
$.clock.sleep(ms)A delay (counts against the hook's time limit)

every and after return a timer with cancel(). Timers stop when the module reloads.

This checks a deploy status every two minutes and keeps a status line current:

on('session.start', async ($, e, next) => {
  $.clock.every(120_000, async () => {
    try {
      const out = await $.process.run(['vercel', 'ls', '--json'], { timeoutMs: 20000 })
      const latest = JSON.parse(out.stdout)[0]
      $.ui.status('deploy: ' + (latest ? latest.state : 'none'))
    } catch {
      $.ui.status('deploy: unknown')
    }
  })
  return next(e)   // do not wait for the timer
})

Timer callbacks run outside any event, so they keep going between turns and never start one by themselves. A throwing callback logs to the debug log and runs again next interval.

Showing something without a turn

CallWhere it appears
$.ui.status(text)One persistent line under the prompt, prefixed ⚠ and the mod name, replaced by the next call
$.ui.toast(text)A notification top right, with the mod name, gone after a few seconds
$.ui.log(text)A dim ● line in the transcript that Claude does not read
$.ui.log(text, { to: 'debug' })A line in the debug log instead

Starting a turn from the background

$.prompt.submit({ text }) starts a turn when the session is idle. Claude sees the text introduced as coming from your mod; add asUser: true to present it as the user's own words. It resolves when the turn starts, so do not await it inside a handler that runs while Claude is working.

Stopping long work

Besides timers resetting on reload, next.signal is an AbortSignal that fires when the event you are handling is abandoned (for example the user interrupts). Pass it to anything long-running.

Messaging other sessions

$.session.send({ to, text }) delivers plain text to another of your sessions, or to one of this session's subagents, using the same channel as the SendMessage tool. to is { sessionId }, { agentId } (from $.agent.list()), or the address string a received message came from. It resolves { isDelivered: true } once queued, or { isDelivered: false, reason }.

on('command.run', { command: 'handoff' }, async ($, e) => {
  const [sessionId, ...rest] = e.args.split(' ')
  const sent = await $.session.send({ to: { sessionId }, text: 'Handoff from another session: ' + rest.join(' ') })
  if (!sent.isDelivered) $.ui.toast('Not delivered: ' + sent.reason)
  return {}
})

To observe traffic, hook these and return next(e) to let messages through:

EventWhenFields
session.receiveA message arrives, before Claude reads ite.text; e.origin.kind such as peer, peer-send-message, task-notification, scheduled-trigger. Return { consumed: reason } to hide it from Claude
session.sendA message is about to leavee.to, e.text, e.origin.kind (model or plugin)

Sessions set to refuse inbound messages drop them before session.receive fires. A message held for your approval reaches the hook first, so a mod can read messages you have not approved; the hook's next(e) rejects if the message is not delivered. Treat the sender name as untrusted: it is whatever the sender wrote. See /docs/cross-session-messaging.

Files, processes and the network

All of these run with the permissions of the user running Claude Code. Standard JavaScript and web APIs (URL, TextEncoder, AbortController, crypto.subtle) work as normal.

NamespaceWhat you get
$.fsread(path), write(path, text), exists, stat, list, ancestors. Relative paths resolve against the session's working directory. list returns one level of { name, kind, size, isLink }
$.processrun(argv, init) with no shell, resolving { exitCode, stdout, stderr } whatever the exit code. Rejects if the program cannot start or exceeds its timeout (30 s default), so use try/catch. spawn streams output from long-running commands
$.httpfetch(url, init) over http or https, resolving { status, ok, headers, text } once the body is read
$.storeYour plugin's own JSON key-value store, kept between sessions
$.envget, set. Write variable names as string literals so validation can see them
$.settingsread what settings files and managed policy contain
$.sessionmessages() (transcript as { role, text, toolUses }), working directory, model, usage() for context and plan limits, and more
$.mcpcall a tool on a connected server; connect a server your own manifest lists

A worked example: a /todo-scan command that lists TODO comments in changed files:

on('command.run', { command: 'todo-scan' }, async ($) => {
  const diff = await $.process.run(['git', 'diff', '--name-only', 'HEAD'])
  const files = diff.stdout.split('\n').filter(Boolean)
  const hits = []
  for (const file of files) {
    if (!(await $.fs.exists(file))) continue
    const lines = (await $.fs.read(file)).split('\n')
    lines.forEach((line, i) => { if (/\bTODO\b/.test(line)) hits.push(file + ':' + (i + 1) + '  ' + line.trim()) })
  }
  return { text: hits.length ? hits.join('\n') : 'No TODOs in changed files' }
})

Other mods can intercept your calls

Every API call is also an event named after it without $., such as fs.read or process.run. A mod earlier in the chain can observe, rewrite or refuse your calls; that is how organisations restrict mods (see /docs/plugins/mods/admin).

A refusal of $.process.spawn can arrive after the command has produced output or exited, and nothing it did is undone. The rejection message then ends with either $.process.spawn started, and a plugin withheld its result: (the refusing mod had not read all the output; Claude Code stops the command if still running) or $.process.spawn ran, and a plugin withheld its result: (the command had already exited), followed by the refusing mod's reason.