Sweep 19 Sep 2026 · 02:36Z Build v2.1.278 500 read Stable v2.1.267 Latest v2.1.278 Next v2.1.278 Feeds RSS JSON llms.txt Unofficial
Mods · 07 of 10

userConfig and options

Declaring options in the manifest, what register() receives, and the two ways a manifest loads and then fails.

A mod takes configuration through the second argument to register. You declare the fields in plugin.json, the person running your mod fills them in through settings, and the engine hands you a frozen object. None of the three mods Anthropic ships uses it, so the shipped source teaches you nothing here, and the failure modes are worse than the shape suggests. Everything below I measured at 2.1.272.

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 inside a field is refused with userConfig.<name>: Invalid input and no more detail than that, so a typo costs you a bisect.

Three keys are required on every field

type, title and description. All three, on all five types, always. title is not cosmetic: it is the name the loader uses when it rejects your value, so a field whose title is different from its key makes the error harder to place.

Five types, and no more

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

There is no array type. A list is a string with "multiple": true. There is no enum type either; a closed set is a string with options.

Seven optional keys

default, required, sensitive, multiple, options, min, max. All seven are accepted on all five types, which is looser than it sounds: min on a boolean passes validation and means nothing, and an inverted {"min": 9, "max": 1} is accepted without complaint.

Four cross-field rules are enforced, and the messages are precise 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

Three ways this goes wrong

The validator does not type-check your default. The loader does.

This one is worth the whole page. Declare a number field with a string default:

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

claude plugin validate says:

✔ Validation passed with warnings

Then at load, the whole module is dropped:

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

Read that error carefully, because it is actively misleading twice. It names the field by its title, so if your title and key differ you are hunting. And it blames settings.json for a value that came out of your own manifest's default, so you will go and look at a file that has nothing in it. Nothing was configured. The default was wrong.

required: true with nothing configured stops the module loading

Needed is required but not provided

Not a warning, not a default, not a degraded mode. The module does not load and its hooks do not exist. required: true is therefore a promise that every install of your mod is configured before first run, and if it is not, your mod is silently absent. Prefer a default and a runtime check you control.

Project settings are never read

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

Three sources are consulted: user settings, --settings, and managed settings. A repository cannot ship configuration for a mod in its project settings, which is a reasonable security position and an easy day to lose if you assume otherwise. The message is helpful and it only appears in the debug log.

Configuring one

Keyed by plugin name, and the key carries the load source:

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

@inline is what --plugin-dir produces. The error messages tell you which two keys the loader will look for, which is the reliable way to find out what your install is called.

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

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 present. multiple gives you a real array.

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

A sensitive value arrives in the clear. The flag is about how the value is presented for entry rather than how it is handled afterwards. Treat it like any other secret in your own code: never log it, never put it in a $.ui.log, never attach it to a prompt.

A declared option with no default reads as the empty string. Never undefined. And "myKey" in options is true. So this never fires:

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

Check for emptiness, or better, declare a default in the manifest so there is one source of truth for what the fallback is:

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

The other config, which is a different thing

$.config is unrelated to userConfig. $.config.set and $.config.list are runtime configuration rows your mod owns, with config.set and config.describe as hookable seams, so another plugin can see or refuse a row being written. userConfig is what the person declared before your code ran. $.config is what your code manages while it runs.

Reading the person's actual settings

$.settings.read() gives you the resolved Claude Code settings with their source attached. SettingsSource is 'user' | 'project' | 'local' | 'flag' | 'policy', so you can tell a value somebody typed from one an organisation imposed.

On a managed machine this is one of the seams sec-default sits above from the prepend tier, so a user-tier mod on such a machine may find its view of settings narrowed. That is deliberate and it is not a bug in your mod.