SpyBara
Go Premium

plugins/mods/api.md 2026-11-11 22:59 UTC to 2026-11-12 23:02 UTC

No changes for this document in this history range.

2026
Mon 2 23:57

Use the mods API

Call the mods API from a Claude Code mod to add commands and tools, call a model, run work on a timer, message other sessions, and reach files and the network.

The mods API is the set of methods a mod calls to act: add commands and tools, call a model, run work between events, and reach the file system, processes, and the network. Every hook receives it as its first argument, $, with the methods grouped in namespaces such as $.ui and $.fs. Events decide when a hook runs, and the mods API is what the hook calls once it does.

Build your first mod before you start here. For every method, see mods API methods or read the types for your build.

Add a command or a tool

A mod can add a command for the user to run and a tool for Claude to call. Register both in a session.start hook. Claude Code waits for that hook before the first prompt, so what you register is available from the first turn.

Add a command

A command is for the user. Register it, then handle command.run for its name. This example adds a /standup command that takes an optional number of days:

on('session.start', async ($, e, next) => {
  // Add /standup to the command list, with the description the user sees there
  await $.command.register({ name: 'standup', description: 'Summarize what changed today', argumentHint: '[days]' })
  return next(e)
})

// The matcher limits the hook to /standup, so other commands don't reach it
on('command.run', { command: 'standup' }, async ($, e) => {
  // e.args is the text typed after the command name, or an empty string
  return { text: 'Summary for the last ' + (e.args || '1') + ' day(s): ...' }
})

After the session starts, /standup appears with its description in the list you see when you type /. The argumentHint shows in the prompt after you type the command and a space, as in /standup [days]. When you run /standup 3, the second hook returns Summary for the last 3 day(s): ..., and the transcript shows that text after the plugin's name. The hook never calls next, because the command has no behavior other than yours.

The text you return prints in the transcript and Claude reads it. To print nothing, as a command that only opens a pane does, return {}. To let the command run while Claude is working, add immediate: true to the registration.

Pick a name that no built-in command uses. Type / in a session to see them. $.command.register throws for a taken name, with a message such as "/focus" refused: it is the built-in /focus. A hook that throws is skipped, so the rest of your session.start hook doesn't run either. Register commands last in that hook, or wrap the call in try and catch.

Add a tool

A tool is for Claude. Register it with a name, a description Claude reads, and a JSON Schema for its input. Claude sees it under a longer name made of mcp__, your plugin's name, two underscores, and the name you registered. You handle its calls in a tool.call hook filtered to that full name. This example, from a plugin named my-mod, registers ticket, so the full name is mcp__my-mod__ticket. It gives Claude a tool that looks up a ticket in an issue tracker:

on('session.start', async ($, e, next) => {
  await $.tool.register({
    name: 'ticket',
    // Claude decides when to call the tool from this description
    description: 'Look up a ticket by its id and return its title and status',
    // The arguments Claude has to send: one required string named id
    inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
  })
  return next(e)
})

// The full tool name is mcp__, the plugin's name, and the registered name
on('tool.call', { tool: 'mcp__my-mod__ticket' }, async ($, e) => {
  // The tool's arguments are fields of e, so the id is e.id
  const response = await $.http.fetch('https://tickets.example.com/api/' + encodeURIComponent(e.id))
  // Return a result either way, so Claude learns when the lookup failed
  return { result: response.ok ? response.text : 'Lookup failed with status ' + response.status }
})

When you ask about a ticket, Claude can call mcp__my-mod__ticket with its id. The second hook fetches the ticket and returns the response body, which Claude reads as the tool's result. When the server answers with an error status, Claude reads Lookup failed with status and the number.

[!TIP]

When MCP tool search defers a registered tool, Claude sees its name but not its description until it searches for it. If Claude should consider the tool on every turn, add isDeferred: false to the registration to load the full tool upfront. The field requires Claude Code v2.1.293 or later, and earlier versions ignore it.

Call a model

A mod can send its own requests to a model for a small job such as classifying or summarizing text. $.model.complete sends your prompt on its own, and $.model.fork({ prompt }) sends the current conversation with your prompt at the end.

This table compares what each request contains:

In the request $.model.complete $.model.fork
Model The model you pass The session's model
System prompt A short attribution block, then your system if you pass one The session's system prompt
Messages One user message, your prompt The conversation so far, then your prompt as a user message
CLAUDE.md and other project context Not included Included, as in the conversation's last request
Tools None Claude's tools, which the model can't call

A fork repeats the conversation's last request, so the Claude API serves most of it from the prompt cache while the conversation is still cached.

Both calls use the session's credentials, so they bill to the user's plan, API key, or cloud provider. The types for your build document every $.model method.

Send one prompt

Pass model and prompt to $.model.complete. prompt becomes the user message. To give the model instructions, such as a role or an output format, also pass system, which becomes the system prompt.

This hook answers a /triage command, registered as a command, by asking a small model to label the text typed after it:

on('command.run', { command: 'triage' }, async ($, e) => {
  const r = await $.model.complete({
    model: 'haiku',
    // The system prompt sets the job, and the prompt carries the text to label
    system: 'Reply with one word: bug, feature, or question.',
    prompt: e.args,
    // One word needs few tokens, and the call gives up after 15 seconds
    maxTokens: 20,
    timeoutMs: 15000,
  })
  // r.text exists only when the model answered, so check r.isAnswered first
  const label = r.isAnswered ? r.text.trim() : 'unknown'
  return { text: 'Label: ' + label }
})

When you run /triage the export button does nothing, the mod sends that text to the model and prints its answer, such as Label: bug. When the model doesn't answer, the label is unknown.

A Claude API failure doesn't reject the call, so check r.isAnswered, and read r.reason when it's false. The call rejects for a request Claude Code won't send, such as a model your organization blocks.

The types for your build list the other options, such as effort, and the limits give the maxTokens default.

Use prompt caching

$.model.complete supports the Claude API's prompt caching. The API caches the start of a request, called the prefix, up to a cache breakpoint that you set. When every call starts with the same long static content, such as instructions or reference material, set a breakpoint at the end of that content. Later calls then read it from the cache instead of paying the full input price for it.

To set a breakpoint, pass prompt as an array of { text } blocks instead of a string, and add cache: true to the last block of the static content. Claude Code sends that block with the API's cache_control field. system takes the same array form. To decide between them, see Choose between prompt and system.

[!NOTE]

Arrays of blocks require Claude Code v2.1.292 or later. Earlier versions reject an array in prompt with an error that ends with takes { model, prompt } (host check), and they leave an array in system out of the request.

This version of the /triage hook sends a long set of labeling rules before the text to label, with a breakpoint after the rules. RULES is a string of your own:

on('command.run', { command: 'triage' }, async ($, e) => {
  const r = await $.model.complete({
    model: 'haiku',
    prompt: [
      // Identical on every call, so it forms the cached prefix
      { text: RULES, cache: true },
      // Changes on every call, so it goes after the breakpoint
      { text: e.args },
    ],
  })
  return { text: 'Label: ' + (r.isAnswered ? r.text.trim() : 'unknown') }
})

The TTL and the number of breakpoints have these limits:

  • TTL: a cache entry lasts five minutes after its last use. The TTL comes from the user's Claude Code settings, not from the call. For one hour, set subagentPromptCacheTtl to 1h.
  • Breakpoints per request: the API accepts up to four, and one more comes back as an api-error in r.reason

Choose between prompt and system

Put the static content that your calls share at the start of prompt unless you know that your requests go directly to the Claude API:

  • Directly to the Claude API, with an API key or a Claude subscription: either field works
  • Through Amazon Bedrock, Claude Platform on AWS, Google Cloud's Agent Platform, Microsoft Foundry, or an LLM gateway: use prompt. Claude Code begins the system prompt with an attribution block whose fingerprint comes from the start of the user message. The api.anthropic.com endpoint strips that block before caching. Other endpoints receive it as part of the prompt, so a breakpoint in system can miss when prompt starts differently.
  • In a mod that other people run: use prompt, because you don't choose their provider

system comes before prompt in the prefix, so a breakpoint in prompt covers system too, and a call with a different system misses the cache.

Check for cache hits

The result of $.model.complete has a usage object with the API's cache fields. usage.cache_creation_input_tokens counts the tokens the call wrote to the cache, and usage.cache_read_input_tokens counts the tokens it read from the cache. Expect a write on the first call and reads on later calls within the TTL.

If every call writes and none reads, the prefix differs between calls or the calls are further apart than the TTL. For a prefix that differs, see Choose between prompt and system.

If both fields stay at zero on calls the model answered, nothing was cached. Check for each of these causes:

What a model.complete hook receives

If you hook the model.complete event to inspect or change other mods' requests, read the text from these fields:

  • e.prompt: always a string. When the caller passed an array, it's the blocks' text concatenated in order.
  • e.system: a string built the same way, or absent when the caller passed no system
  • e.promptBlocks and e.systemBlocks: the caller's arrays, each present when the caller passed an array for that field

Claude Code sends the strings your hook passes to next and uses the arrays you pass with them to place cache breakpoints. It keeps the leading blocks that still match the start of the string, with their breakpoints, and sends the rest of the string with no breakpoint. For example, next({ ...e, prompt: e.prompt + NOTE }) keeps the caller's breakpoints, and a hook that changes the start of prompt removes them.

Run work in the background

Work that outlives one event, such as checking on something once a minute, runs on a timer you start from session.start. A hook itself runs for one event and has a time limit on its own execution time. Time spent waiting on next or on a mods API call doesn't count, except a $.clock.sleep. $.clock.every and $.clock.after take the place of setInterval and setTimeout, with the delay in milliseconds first: $.clock.after(5000, fn) calls fn once, five seconds from now. Each returns a timer with a cancel() method, and await $.clock.now() gives the time in milliseconds.

This hook looks up a pull request's checks once a minute and shows the result under the prompt. summarize is a function of your own that turns the command's JSON output into a few words:

on('session.start', async ($, e, next) => {
  // Call the function every 60,000 milliseconds, starting one minute from now
  $.clock.every(60_000, async () => {
    const status = await $.process.run(['gh', 'pr', 'checks', '--json', 'state'])
    // Replace the line under the prompt with the latest summary
    $.ui.status('checks: ' + summarize(status.stdout))
  })
  // Return without waiting for the timer, so the session starts right away
  return next(e)
})

The session starts as usual. A minute later, a line appears under the prompt with a ⚠, the mod's name, and then checks: and your summary. It's replaced once a minute after that. The timer's callback runs outside any event, so it keeps running between turns and doesn't start one. If the callback throws, the error goes to the debug log and the timer runs again at the next interval.

Show something without starting a turn

A background job can show the user something without starting a turn. Each of these calls puts text in a different place:

Call What the user sees
$.ui.status(text) One line under the prompt that stays until you change it. It starts with ⚠ and the mod's name, as in ⚠ my-mod: checks: 3 passing.
$.ui.toast(text) A toast notification with the mod's name that disappears after a few seconds. It's a box at the top right in fullscreen rendering, and one line at the right under the prompt in the classic renderer.
$.ui.log(text) A dim line in the transcript that Claude doesn't read. It starts with ● and the mod's name, as in ● my-mod: build finished.
$.ui.notify(text) A notification from their terminal, outside Claude Code, sent the way their preferredNotifChannel setting says. The mod's name is its title unless you pass a non-empty { title }, which is shown as you give it.

$.ui.notify is the only one of these that answers. It resolves to { isSent: true, channel } when the user's channel wrote the notification to their terminal. channel is the setting's value that sent it, such as kitty or terminal_bell. The terminal_bell channel rings the bell and shows neither your text nor the title.

Otherwise the call resolves to { isSent: false, reason }:

reason Meaning
disabled The user set preferredNotifChannel to notifications_disabled
no-channel The setting is auto and their terminal has no notification Claude Code can send
no-surface The session has no terminal, as in a -p run, the Agent SDK, the desktop app, the VS Code extension, or a cloud session. Also the answer to a call made before the session starts, while your mod is loading, and to a call made while Claude Code is exiting
refused Another mod's hook on ui.notify answered in Claude Code's place without sending

The call rejects instead of resolving when text isn't a string, when another mod's hook on ui.notify returns { deny }, or when writing to the terminal fails.

Your mod can't choose the channel, and Claude Code doesn't limit how often a mod notifies. The user's setting is the only switch.

When the call reaches a running session, the user's Notification hooks run with the type plugin_notification. They run even when the answer is disabled, and in a session with no terminal, where the answer is no-surface. No hook runs for a call made before the session starts or while Claude Code is exiting. The hook your mod called $.ui.notify from is left out of that run, so a classic.Notification hook that notifies isn't called again for its own notification.

A terminal doesn't report whether it showed the notification, so isSent: true means it was sent, not that the user saw it.

This example notifies when the main conversation's turn ends without an interruption, and falls back to a toast when no notification with text went out. It doesn't wait for the notification before passing the event on, because the user's Notification hooks run first and can take a while:

on('turn.complete', ($, e, next) => {
  const isMainTurn = e.agentId === undefined && !e.isAborted
  const fallBack = () => $.ui.toast('Turn finished')
  if (isMainTurn) {
    $.ui.notify('Turn finished').then(sent => {
      const hasText = sent.isSent && sent.channel !== 'terminal_bell'
      if (!hasText) fallBack()
    }, fallBack)
  }
  return next(e)
})

In the VS Code extension, status lines and toasts show above the prompt box instead, and log lines in the conversation.

Start a turn from a background job

When a background job finds something that needs Claude's attention, it can start a turn by submitting a prompt with $.prompt.submit({ text }). Claude reads the text after a sentence that names your mod as the sender. To send it as the user's own words, without that sentence, add asUser: true. The call waits until the session is idle and then starts a new turn. It resolves when that turn starts, so don't await it in a handler that runs while Claude is working.

Stop background work

Timers stop after the module reloads. For long-running work inside a hook, next.signal is an AbortSignal that aborts when the event your hook is handling is abandoned, for example when the user interrupts. Pass it to anything long-running.

Calls and background work after the user interrupts a turn

The reason the session was stopped and nobody has sent input since in a result or error means the user stopped a running turn, such as with Esc or Stop. After that interruption, calls that start new work in the session are refused until the user's next input, such as a message at the prompt.

Apart from $.prompt.submit and $.command.run, this handling of calls and background work after an interruption requires Claude Code v2.1.304 or later.

Retry a refused or dropped call

Make a refused or dropped call again from a turn.complete hook. Calls stay refused until the user's next input, and nothing queues or repeats them for you.

When a call's result or error contains one of these strings, keep its arguments for a later retry:

  • the session was stopped and nobody has sent input since: the call was refused
  • was removed from the queue before it ran: the prompt or command left the queue without running and was dropped, for example when the user stopped the turn

In this example, call submitPrompt from your background job in place of $.prompt.submit. Put the pendingText variable and the submitPrompt function above register, and the turn.complete hook inside it, because validation refuses $ passed to a function declared inside register. When the result or the error carries one of those strings, submitPrompt keeps the text in pendingText for the turn.complete hook to submit again:

// The text of the last refused or dropped prompt, or null
let pendingText = null

async function submitPrompt($, text) {
  let reason
  try {
    const result = await $.prompt.submit({ text })
    reason = result?.drop ?? ''
  } catch (error) {
    reason = String(error?.message ?? error)
  }
  if (reason.includes('the session was stopped and nobody has sent input since') ||
      reason.includes('was removed from the queue before it ran')) {
    pendingText = text
  }
  // Any other result or error isn't retried
}

on('turn.complete', async ($, e, next) => {
  if (pendingText !== null) {
    const text = pendingText
    // Empty pendingText before retrying, so that a second run of this hook
    // doesn't submit the same text.
    pendingText = null
    // Not awaited: the hook would stay open until the new turn starts
    submitPrompt($, text)
  }
  return next(e)
})

Before the user's next input, turn.complete can fire for the interrupted turn itself and for a subagent's turn, and calls made then are refused again. In that case submitPrompt keeps the text for a later turn.complete.

For a call that rejects, such as $.command.run, catch the error and test its message the way submitPrompt tests reason.

What refused calls return

In the results, <reason> stands for the reason the call was refused. After an interruption, that's the session was stopped and nobody has sent input since. For other reasons, see Calls refused in claude -p runs and Agent SDK sessions.

Some refused calls Result
$.prompt.submit Resolves with { drop: '<reason>' } and queues no prompt
$.command.run Rejects with an error whose message ends with <reason>
$.agent.spawn Resolves with { deny: '<reason>' } and starts no subagent
$.session.send to a subagent that finished Resolves with { isDelivered: false, reason: 'Agent "<id>" was not resumed: <reason>.' }, where <id> is the subagent's id. A subagent that's still running receives the message.
$.session.send to an agent team teammate Resolves with { isDelivered: false, reason: 'Teammate "<name>" was not messaged: <reason>.' }, where <name> is the teammate's name
$.tool.call of Bash with run_in_background: true, Monitor, or CronCreate Resolves with { deny: '<tool> was not run: it starts background work, a schedule or a goal, and <reason>' }, where <tool> is the tool's name

When one of these $.session.send or $.tool.call calls is refused after an interruption in an interactive session, the user can see the Stopped: toast.

Work your mod started before the interruption

After the user stops a running turn, for example with Esc, some work your mod had already started is dropped and some waits for the user's next input.

Work What happens until the user's next input What to do
A prompt from $.prompt.submit still in the queue Dropped. The call resolves with { drop: 'the prompt was removed from the queue before it ran' }. Submit it again as Retry a refused or dropped call shows
A command from $.command.run still in the queue Dropped. The call rejects with an error whose message includes the command was removed from the queue before it ran. Run it again as Retry a refused or dropped call shows
A one-time job scheduled with a tool such as CronCreate that comes due before the user's next input Starts no turn then, and runs after that input Nothing needed
A recurring job scheduled with a tool such as CronCreate that comes due before the user's next input Starts no turn then. The job loses that run and keeps its schedule. Nothing needed
An event from a monitor your mod started The monitor keeps running. An event the monitor raises before the user's next input is dropped, except the notice that the monitor ended, which arrives after that input. Nothing can be done
A subagent your mod spawned and later resumed with $.session.send that finishes before the user's next input Its finish notice waits, and reaches the conversation after that input Nothing needed
A foreground Bash or PowerShell command from $.tool.call that was already running when the user stopped the turn and reaches its timeout before their next input Stopped at its timeout instead of moving to the background. The call resolves with isError set, and the output includes the line Ended instead of moving to the background: plugins' input is held, by a Stop or by the host's limits. Run it again after the user's next input, as Retry a refused or dropped call shows

When a scheduled job comes due or a monitor raises an event before the user's next input, the user can see the Stopped: toast.

Calls refused in claude -p runs and Agent SDK sessions

In a claude -p run or an Agent SDK session, a result such as { drop: 'the run reached its --max-turns limit' } means the run refused your mod's call.

A refused $.prompt.submit resolves with { drop: '<reason>' }. Other refused calls, such as $.command.run and $.agent.spawn, give the results shown in What refused calls return with the reason in place of <reason>. Find your <reason> below for what to do about it:

  • the run reached its --max-turns limit: turns from your mod's prompts and from subagents it spawns count toward --max-turns or the Agent SDK's turn limit. For more turns, start the run with a higher limit. If you can't, have your mod stop making the refused call. If the run takes another prompt from the user or the SDK application, the turn count resets and the run accepts calls again.
  • the run is past its --max-budget-usd limit: start the run with a higher --max-budget-usd, or raise the Agent SDK's spend limit. If you can't, have your mod stop making the refused call. The limit applies to the run's total cost so far. That total keeps adding up when the run takes another prompt, so a retry returns the same reason.
  • the host has ended its input: for example, the SDK application ended its streaming input, or stdin closed on a run started with --input-format stream-json. The run is ending and takes no more input, so a retry returns the same reason. Have your mod stop making the refused call. If you start the run yourself, next time keep that input open until your mod has no more calls to make.
  • the session was stopped and nobody has sent input since: the run was interrupted, for example with the Agent SDK's interrupt(). To make the call again after the next message from the user or the SDK application, follow Retry a refused or dropped call.

A claude -p run with your mod never exits

If your mod submits a prompt at the end of every turn, each prompt starts another turn. Without --max-turns or --max-budget-usd, a claude -p run that loads the mod goes on until you stop it yourself.

To make the run end on its own, add either flag to the command in your shell. If other people start the run, have your mod stop submitting prompts once its work is done. This command adds --max-turns:

claude -p "find the TODOs" --max-turns 5

Once the run reaches that limit, the mod's next prompt is refused instead of starting a turn, and the run ends.

If the run still goes past the limit, run claude --version in your shell, and update Claude Code if yours is earlier than v2.1.304. The mod's prompts are refused at the limit from v2.1.299. If your mod spawns subagents, their turns count toward --max-turns from v2.1.304.

Send and receive messages between sessions

A mod can send a plain-text message to another of your sessions, to one of this session's subagents, or to a teammate in its agent team. It can also observe the messages that arrive and leave.

To send one, call $.session.send({ to, text }), which makes the same delivery the SendMessage tool makes. Set to by who receives the message:

  • Another of your sessions: { sessionId }
  • A subagent or teammate: { agentId }, with an id from $.agent.list()
  • The sender of a message you received: the string address that message came from

The call resolves once the message is queued, with { isDelivered: true }. When nothing was delivered, it resolves with { isDelivered: false, reason }, and reason says why.

This hook answers a /ping command, registered as a command, by asking the session whose id you type after it for a status:

on('command.run', { command: 'ping' }, async ($, e) => {
  // e.args is the session id typed after /ping
  const sent = await $.session.send({ to: { sessionId: e.args }, text: 'Status? One line.' })
  // The call resolves either way, so check isDelivered to learn what happened
  if (!sent.isDelivered) $.ui.toast('Not delivered: ' + sent.reason)
  // An empty result prints nothing in this session's transcript
  return {}
})

When the message is queued, nothing appears in your session, and the other session's Claude reads Status? One line. When nothing was delivered, a toast notification gives the reason.

session.receive and session.send let a mod observe the messages. Return next(e) from both to pass each message through unchanged:

Event Fires when Useful fields
session.receive A message arrives for this session, before Claude reads it e.text, and e.origin.kind, such as peer or peer-send-message for another session or agent, task-notification, or scheduled-trigger. Return { consumed: reason } to keep it from Claude.
session.send A message is about to leave, from the SendMessage tool or a mod e.to, e.text, and e.origin.kind, which is model or plugin

A session set to refuse inbound messages refuses a message before session.receive fires, so a hook never sees it. A message that's held for your approval reaches the hook first, so a mod can read a message you haven't approved yet. The hook's next(e) rejects when the message isn't delivered.

The sender's name on a received message is whatever the sender wrote, so don't base a decision on it.

Reach files, processes, and the network

A mod reaches the file system, processes, and the network through the mods API, with the same permissions as the user running Claude Code. The hooks module itself has no Node.js APIs, no timer globals such as setTimeout, and no network or file access of its own. Standard JavaScript and web APIs such as URL, TextEncoder, AbortController, and crypto.subtle are available. Each namespace below covers one kind of access:

Namespace What it does
$.fs read(path), write(path, text), exists(path), stat(path), and list(path) work on files and directories
$.process run(['git', 'status']) starts a command and resolves when it exits. spawn streams a long-running command's output.
$.http fetch(url, init) over http or https. It resolves to { status, ok, headers, text } once the body is read.
$.store A JSON key-value store of your plugin's own, kept between sessions
$.env get and set environment variables. Write the name as a string literal.
$.settings read() resolves to all settings, merged into one object. With { source } or { key }, it resolves to one source's settings or one key's value instead.
$.session messages() returns the transcript as a list of { role, text, toolUses }. Also the working directory, model, and more. usage() returns context window use and plan limits.
$.mcp call a tool on a connected MCP server

Files, processes, and requests have a few rules of their own:

  • Paths: a relative path resolves against the working directory of the session, or of the subagent whose event the hook is handling
  • $.fs.list: returns one directory's entries as { name, kind, size, isLink } and isn't recursive
  • $.process.run: takes an argument list and uses no shell. It resolves to { exitCode, stdout, stderr } whatever the exit code. It rejects if the program can't start or is still running at the timeout, which is 30 seconds by default, so wrap it in try and catch.
  • $.http.fetch: follows up to five redirects. On a redirect to a different origin, it keeps only the accept, accept-language, content-type, and user-agent request headers you set and drops the rest, so a request that depends on another header, such as Authorization, can fail after that redirect. The limits give its timeout and body sizes.

Every one of these calls is itself an event, named for its namespace and method without the $., such as fs.read for $.fs.read. A mod earlier in the chain can observe, rewrite, or refuse your call, which is how an organization restricts what mods reach.

A mod can refuse your $.process.spawn call after the command has produced output or exited, and nothing the command did is undone. The call then rejects with a message that ends with one of these strings and the refusing mod's reason:

  • $.process.spawn started, and a plugin withheld its result:: the refusing mod hadn't read the command's output to the end. Claude Code stops the command if it's still running.
  • $.process.spawn ran, and a plugin withheld its result:: the refusing mod had read the command's output to the end, so the command had exited

Read all settings, one source, or one key

$.settings.read() resolves to one object that holds the settings from all sources in use, merged by precedence. Pass { source } to read one source instead, { key } to read one key, or both:

Call Resolves to
$.settings.read() The merged settings as one object. Its top-level keys are those of a settings.json file, such as permissions and hooks.
$.settings.read({ source: 'policy' }) That one source's settings as one object, or {} when that source holds no settings
$.settings.read({ key: 'spinnerTipsEnabled' }) That key's value in the merged settings, or undefined when the key isn't set
$.settings.read({ key: 'spinnerTipsEnabled', source: 'user' }) That key's value in that one source, or undefined when that source doesn't set it

For source, pass the name of the one settings source to read:

  • user: ~/.claude/settings.json
  • project: .claude/settings.json
  • local: .claude/settings.local.json
  • flag: what you pass with --settings, and the SDK's inline settings
  • policy: managed settings

For key, pass the exact name of one top-level key, not a dotted path into a nested object. The key argument requires Claude Code v2.1.304 or later.

Next steps