Follow Discord
Sweep 06 Nov 2026 · 01:32Z Build v2.1.319 537 read Stable v2.1.311 Latest v2.1.318 Next v2.1.319 Feeds RSS JSON llms.txt llms-full.txt Unofficial
Mods · 07 of 10

userConfig and options

Declaring options in plugin.json, what register() gets, and the three ways a config stops your mod loading.

Read against 2.1.286 Anthropic's page: Plugin manifest: user configuration 11,589 characters 6 sections config.json, paged

A mod gets its configuration as the second argument to register. You declare the fields in plugin.json, the person running your mod fills them in through their settings, and the engine hands you a frozen object. At 2.1.272 none of Anthropic's own mods used it, so their source won't teach you anything here, and the ways it goes wrong are worse than the shape suggests. I measured all of this at 2.1.272 and re-ran the failure cases at 2.1.283 and again at 2.1.286, where the schema hasn't changed.

export function register(on: On, options: PluginOptions) {
  const endpoint = options.endpoint

  on('turn.complete', async ($, e, next) => { /* ... */ })
}

Declaring the fields

{
  "name": "my-mod",
  "version": "0.0.1",
  "description": "...",
  "userConfig": {
    "endpoint": {
      "type": "string",
      "title": "Endpoint",
      "description": "Where to post the summary.",
      "default": "https://example.test/hook"
    },
    "timeoutMs": {
      "type": "number",
      "title": "Timeout",
      "description": "How long to wait, in milliseconds.",
      "default": 2000
    }
  }
}

The object is strict. An unknown key anywhere in a field is refused with userConfig.<name>: Invalid input and nothing more, so a typo means bisecting your manifest.

Field names have to match ^[A-Za-z_]\w*$, since each one is also exported to your hooks' environment as CLAUDE_PLUGIN_OPTION_ plus the name in capitals, so apiUrl becomes CLAUDE_PLUGIN_OPTION_APIURL.

Every field needs three keys

type, title and description, on every field of every type. title isn't just for show, because it's the name the loader uses when it rejects a value. A field whose title doesn't match its key makes that error harder to trace.

Five types

AcceptedRejected
stringinteger, float, select, multiselect, secret, password, enum, list, array, object, text, path
number
boolean
file
directory

There's no array type, so a list is a string with "multiple": true. There's no enum type either, and a fixed set of choices is a string with options.

Seven optional keys

default, required, sensitive, multiple, options, min and max. The validator accepts all seven on all five types, which is looser than it sounds. min on a boolean passes and does nothing, since min and max only apply to numbers, and a backwards {"min": 9, "max": 1} goes through without a word.

Four rules across fields are enforced, with messages clear enough to quote:

options is only for a field of type "string" that is neither multiple nor sensitive
options needs at least one value
default must be one of the options: x, y
a field with options needs a default among them, or required: true

Each value in options has to be non-empty, 64 characters at most, with no control characters and no leading or trailing space, and no two can differ only by case.

Three ways this goes wrong

The validator doesn't check your default's type. The loader does.

This is the one to remember from this page. Declare a number field with a string default:

{ "type": "number", "title": "Bad", "description": "d", "default": "three" }

claude plugin validate says, at 2.1.286:

✔ Validation passed

It used to say "passed with warnings", and the only warning was that I'd left out author. Add one and the bad default gets a clean pass. Then when it loads, the whole module is dropped, with the same text at 2.1.286:

[ERROR] hooks module pbadefault@inline failed to load: pbadefault: options do not fit plugin.json userConfig:
  Bad must be a number (settings.json pluginConfigs["pbadefault" or "pbadefault@inline"].options)

That error misleads you twice. It names the field by its title, so if the title and key differ you have to go looking. And it blames settings.json for a value that came from your own manifest's default, so you'll go and open a file with nothing in it. Nothing was configured. The default was wrong.

Since 2.1.283 a failed load in a claude -p run with --plugin-dir also prints a line to plain stderr, pbadefault: hooks module did not load: ..., so you'll see it without --debug. The same line shape is still there at 2.1.287. That's the only case where stderr gets it. An interactive session that hot-reloads the mod shows it in the transcript, and for a mod installed from a marketplace it's only in the debug log, as Anthropic's troubleshooting page lays out.

required: true with nothing configured stops the module loading

Needed is required but not provided (settings.json pluginConfigs[...])

That's the 2.1.286 text too.

It's not a warning, there's no fallback, and nothing half works. The module doesn't load and none of its hooks exist. So required: true means every install of your mod has to be configured before its first run, and when one isn't, your mod just isn't there. A default plus a check in your own code is usually the better choice.

Project settings are never read

plugin auditone: no pluginConfigs["auditone" or "auditone@inline"].options in user, --settings or
managed settings (project settings are not read); every option is its default

Claude Code looks in three places: user settings, --settings and managed settings. A repository can't ship configuration for a mod in its project settings. That's a reasonable security call, and it's an easy afternoon to lose if you assume otherwise. The message spells it out, but only in the debug log. It reads the same at 2.1.286.

Configuring one

Configuration is keyed by plugin name, and the key includes where it was loaded from:

{
  "pluginConfigs": {
    "my-mod@inline": {
      "options": { "endpoint": "https://example.test/hook", "timeoutMs": 500 }
    }
  }
}

@inline is what --plugin-dir gives you. The error messages name both keys the loader looks for, which is the easy way to find out what your install is called.

claude --plugin-dir ./my-mod --settings ./dev-settings.json

A stored value that isn't one of a field's options used to read as unset. At 2.1.286 it reads as the field's default, and the log says so at error level: popts: option mode in settings is not one of fast, slow; it reads as the default, fast.

At 2.1.283 every plugin without a userConfig also logged options requested but its manifest declares no userConfig; every option reads as absent as a warning. That line isn't in the 2.1.286 binary, and a mod with no userConfig that reads options just gets undefined for each key.

From the command line

Since 2.1.285 there's claude plugin configure <plugin>, which wants the full id, name@marketplace. On its own it only reads. This is 2.1.286 on the changelog plugin:

Options for cc-changelog (cc-changelog@cc-changelog):
  ❯ baseUrl — Site base URL (optional, not set)

Set values with /plugin configure <plugin> in Claude Code, or pipe a JSON object to `claude plugin configure <plugin> --values-stdin`.

--json gives you the schema, the current inputs, and lists of configured and unconfigured keys, which is the easy way to check what an install actually has. --values-stdin writes. Its help says it reads "a JSON object of single-line strings; options left out keep their values", and the 2.1.286 binary refuses stdin over 256 KB. I haven't run it, because it writes the real settings file. claude plugin install also takes --config key=value, repeatable, and its help says the value is "validated against the schema and stored via the same path as the interactive /plugin configure flow". I haven't run that either.

This site's 2.1.285 changelog, read from the bundle, adds three more: each value is capped at 64 KB, a plugin's secrets now merge from every plugin in its dependency chain, and saved options and secrets follow a plugin to its new id when it's renamed. All three are unverified. I haven't run any of them.

In the test kit you pass values straight in, with the options field on a test. That's on the testing page.

What arrives at runtime

The object is frozen. Object.isFrozen(options) is true, and assigning to a key throws Attempted to assign to readonly property. Every declared key is always there, and multiple gives you a real array.

Two things to know before the first line that reads it:

A sensitive value is stored in the keychain, and it still reaches your code in plain text. Treat it like any secret in your own code. Don't log it, don't pass it to $.ui.log, and don't attach it to a prompt.

A declared option with no default reads as an empty string, never undefined, and "myKey" in options is true. At 2.1.286 a mod that declares mode with a default and note without one gets {"mode":"fast","note":""}. So this never falls back:

const endpoint = options.endpoint ?? DEFAULT_ENDPOINT   // options.endpoint is ""

Check for an empty string, or better, give it a default in the manifest so the fallback lives in one place:

const endpoint = typeof options.endpoint === 'string' && options.endpoint !== ''
  ? options.endpoint
  : DEFAULT_ENDPOINT

$.config is the /config menu

$.config is every row of the /config menu, Claude Code's own and each enabled plugin's userConfig fields alike. The 2.1.287 declarations key a plugin's rows <plugin>.<field>. $.config.list() returns the rows the menu would draw, and $.config.set changes one "as if the person did in the menu", which other plugins' hooks can refuse. config.set and config.describe are the events behind that, so another plugin can watch a row being written, refuse it, hide it or relabel it. Anthropic's reference page lists list and set.

In an interactive 2.1.287 session my probe's $.config.list() returned 61 rows, and its own field was one of them:

{"key":"cfgprobe.endpoint","label":"endpoint","description":"An endpoint.","kind":"text","value":"https://example.test/hook","provider":{"plugin":"cfgprobe@inline","tier":"user"},"isLocked":false}

In claude -p the same mod got 40 rows, every one of them {"plugin":"engine","tier":"core"}. No plugin's row was there, not my mod's and not the installed changelog plugin's baseUrl. The declarations don't mention that. So read your own fields from options, which works in both, and treat a plugin row from $.config as something a headless run won't have.

Reading the person's actual settings

$.settings.read() gives you Claude Code's resolved settings with the source of each one. SettingsSource is 'user' | 'project' | 'local' | 'flag' | 'policy', so you can tell something the person set from something their organisation imposed.

On a managed machine cc-plugin-sec-default sits above this from the prepend tier, so a mod in the user tier there may see less of the settings. That's on purpose, and it isn't a bug in your mod.

Feedback