# Extension settings

> Declare an extension's settings once, with every setting type, pages and a sidebar group, scopes, apply modes, locked values from config files and action buttons.

An extension declares its settings in one schema, the `settings` field of the definition. Settings draws the rows. `ctx.settings` reads the values, typed from the schema. The schema is static data, so Settings shows it even while the extension is off.

Use settings for what the user chooses: a model, a base URL, a switch. For data the extension keeps on its own, use [storage](https://zidane.si/docs/extensions-storage.md). For accounts and sign-ins, use [integrations](https://zidane.si/docs/extensions-integrations.md).

## Declare the schema

Write the schema with `as const satisfies SettingsSchema`, so `ctx.settings.get` knows each key and its type. From the kitchen-sink example (`examples/extension-kitchen-sink/settings.ts`), shortened:

```ts
import type { ExtensionContext, SettingsSchema } from 'zidane'
import { defineExtension } from 'zidane'

export const settings = {
  sections: [
    { id: 'general', label: 'General' },
    { id: 'advanced', label: 'Advanced', description: 'Things most people leave alone.' },
  ],
  items: {
    showStatus: { type: 'boolean', label: 'Show the statusbar item', default: true, section: 'general' },
    step: { type: 'number', label: 'Bump step', default: 1, min: 1, max: 10, step: 1, section: 'general' },
    greeting: {
      type: 'string',
      label: 'Greeting',
      default: 'Hello',
      section: 'general',
      validate: value => (value.length > 40 ? 'Keep it under 40 characters.' : undefined),
    },
    flavor: {
      type: 'enum',
      label: 'Flavor',
      options: [{ value: 'sweet', label: 'Sweet' }, { value: 'salty', label: 'Salty' }],
      default: 'sweet',
      section: 'general',
    },
    notes: { type: 'string', label: 'Notes', multiline: true, section: 'advanced', scopes: ['user', 'project', 'session'] },
    token: { type: 'string', label: 'Kitchen token', secret: true, pattern: '^ks_[a-z0-9]+$', section: 'advanced' },
    model: { type: 'model', label: 'Model for summaries', section: 'advanced' },
    fallbacks: { type: 'model', label: 'Fallback models', multi: true, default: [], section: 'advanced' },
    tweakCatalogs: { type: 'boolean', label: 'Tweak catalogs', default: false, apply: 'agent-rebuild', section: 'advanced' },
  },
} as const satisfies SettingsSchema

/** The context the extension's files share, with typed settings. */
export type KitchenContext = ExtensionContext<typeof settings>

export default defineExtension({
  apiVersion: 1,
  id: 'kitchen-sink',
  settings,
  setup(ctx) {
    const step = ctx.settings.get('step') // number | undefined
    const flavor = ctx.settings.get('flavor') // 'sweet' | 'salty' | undefined
    ctx.logger.info('settings', { step, flavor })
  },
})
```

`sections` orders the groups. A setting without `section` shows ungrouped.

## Pages and the settings sidebar

Every extension with settings gets an entry in the settings sidebar, under Extensions: a top-level entry right after Agent. Without `pages`, the extension has one page, titled with its display name. `pages` splits the settings into several pages, and `category` gives the extension a top-level group of its own:

```ts
import type { SettingsSchema } from 'zidane'

export const settings = {
  category: { label: 'Kitchen', icon: { gui: 'i-ph-cooking-pot', tui: '◍' } },
  pages: [
    { id: 'general', label: 'General' },
    { id: 'advanced', label: 'Advanced', description: 'Things most people leave alone.' },
  ],
  sections: [
    { id: 'look', label: 'Look', page: 'general' },
    { id: 'danger', label: 'Danger zone', page: 'advanced' },
  ],
  items: {
    showStatus: { type: 'boolean', label: 'Show the statusbar item', default: true, section: 'look' },
    step: { type: 'number', label: 'Bump step', default: 1, page: 'general' },
    notes: { type: 'string', label: 'Notes', multiline: true, page: 'advanced' },
    reset: { type: 'action', label: 'Reset the counter', button: 'Reset', section: 'danger', run: () => ({ message: 'Counter reset.' }) },
  },
} as const satisfies SettingsSchema
```

| Field | Does |
| - | - |
| `pages` | Ordered pages: `{ id, label, description?, icon? }`. An id is 1 to 64 letters, digits, `_`, `.`, `:` or `-`, unique in the extension |
| `category` | `{ label, icon? }`: a group of its own in the sidebar, after the built-in groups, with the extension's pages under it. Without it, the extension sits under the Extensions group |
| `sections[].page` | The page a section sits on. Default: the first page |
| `page` on a setting | The page of a setting outside any section. A setting in a section takes its section's page; naming another page is refused |

`icon` takes `{ gui?, tui? }`: an icon class for the desktop and web apps, a glyph for the terminal app. In the desktop and web apps, an extension's entry under Extensions shows the extension's `icon` (the definition's, [fields](https://zidane.si/docs/extensions.md#the-definition)), else its only page's, else the first letter of its display name in a small square. A category shows its own icon, else the extension's, else the first letter of its label. A category's page shows its icon, else the first letter of its label. Opened, an extension's entry and its category show the extension's mark beside the title, and its `description` under it (an entry without one, its only page's). A page lists its settings without a section first, then its sections in order. A section or setting that names an unknown page, a page id used twice or a malformed `category` refuses the extension: it does not load, and Settings lists it with the reason.

[`SettingsPage`](https://zidane.si/docs/extensions-placements.md#settings-pages) items add pages after these, in their `order`. Settings search finds every setting of every page, `SettingsPage` schemas included, and opens its page.

A key starts with a letter or `_`, then letters, digits, `_`, `.` or `-`: `baseUrl`, `record-mentions`. `__proto__`, `constructor` and `prototype` are refused.

## Fields of every setting

| Field | Type | Does |
| - | - | - |
| `type` | `'boolean' \| 'number' \| 'string' \| 'enum' \| 'model' \| 'action'` | Required. The kind of setting |
| `label` | `string` | Required. The row's label |
| `description` | `string` | Help text under the label |
| `section` | `string` | The id of a section in `sections` |
| `page` | `string` | The id of a page in `pages`, for a setting outside any section ([pages](#pages-and-the-settings-sidebar)) |
| `scopes` | `('user' \| 'project' \| 'session')[]` | Where the user may set it. Default `['user', 'project']` |
| `apply` | `'live' \| 'agent-rebuild' \| 'restart'` | What a change needs before it applies. Default `'live'` ([apply](#apply)) |

## Setting types

### boolean

A switch. `default?: boolean`.

### number

A number field. `default`, `min` and `max`, which Settings enforces, and `step`, a hint for the input that is never enforced.

### string

A text field.

| Field | Does |
| - | - |
| `default` | The value while unset |
| `placeholder` | Grey text in the empty field |
| `multiline` | A text area |
| `pattern` | A regular expression the value must match |
| `validate(value)` | An extra check. Return an error message to refuse the value |
| `secret` | The value goes to the auth store, shows masked, and never appears in a config file |

### enum

A choice among fixed values. `options: { value, label, description? }[]`, values unique. `get` types the value as the union of the option values.

### model

A model picker. The value is the model id the picker stored, a string. With `multi: true`, the value is a list of ids. The ids are not checked against the catalog: parse them defensively, as the kitchen-sink example does with `provider/model` and `provider:model`.

### action

A button that runs code. It holds no value.

```ts
import type { SettingsSchema } from 'zidane'

export const settings = {
  items: {
    greeting: { type: 'string', label: 'Greeting', default: 'Hello' },
    resetGreeting: {
      type: 'action',
      label: 'Reset the greeting',
      button: 'Reset',
      run: async ({ get, set }) => {
        const was = get('greeting')
        await set('greeting', 'Hello')
        return { message: `Greeting reset (was "${String(was)}").`, tone: 'success' }
      },
      status: ({ get }) => ({ text: `now "${String(get('greeting'))}"`, tone: 'muted' }),
    },
  },
} as const satisfies SettingsSchema
```

| Field | Does |
| - | - |
| `button` | Required. The button's label |
| `run(ctx)` | Required. Runs on a click |
| `status(ctx)` | A live state drawn next to the button: `{ text, tone? }`, `tone` one of `muted`, `success`, `warn`, `error` |
| `scope` | The least token scope a client of a server needs to run it: `'chat'` or `'admin'`. Default `'admin'` |

Settings are static, so an action has no `ctx` of the extension. `run` gets the extension's own settings instead:

| Member | Does |
| - | - |
| `get(key)` | The effective value of a setting of this extension |
| `set(key, value)` | Writes a setting as an edit in Settings would: checked, then stored in the first of its scopes among `user` and `project`. A secret goes to the auth store. `undefined` clears it. Rejects for an unknown, action or [locked](#locked-values) key, or a refused value |
| `signal` | Aborts when the run is cancelled or the extension unloads |

`status` gets `get` only. `run` may return `{ message?, tone?, patch? }`: `message` shows after the run, `tone` is `info`, `success`, `warn` or `error`, and `patch` writes other settings of the extension.

`scope` matters when Settings talks to a server ([tokens and scopes](https://zidane.si/docs/server-protocol.md)). A window whose token is below the action's scope draws the button greyed out, with an "Admin only" tooltip, and the server refuses the run. The terminal app dims the row and tags it `admin`. The app on this computer, in-process or through the local daemon, is never limited. Give `scope: 'chat'` to an action that is safe for any chat client, such as a refresh.

To reach the rest of the extension from an action, keep state in a module variable that `setup` sets, as the [Bolt extension](https://github.com/Tahul/zidane/tree/main/extensions/bolt) does with its config reader.

## Read values

| Member | Does |
| - | - |
| `get(key, { sessionId? })` | The effective value, typed from the schema. `undefined` when unset with no default |
| `onChange(key, listener)` | Calls `listener(value)` when the effective value changes. Returns a `Disposable` |
| `locked(key)` | Whether a config file sets the value ([locked values](#locked-values)) |

`get` reads the layers in this order, and the first that has a valid value wins:

1. `session`, with `sessionId` and when the setting allows that scope
2. `project`
3. `user` (for a secret: the auth store)
4. A config file's entry ([locked values](#locked-values))
5. The schema's `default`

A stored value that no longer passes the setting's checks (a value out of a new range, an option that was removed) is skipped, and the next layer answers.

`get` is cheap and always current. Read it where you use the value, rather than once in `setup`.

```ts
ctx.settings.onChange('tweakCatalogs', () => {
  void ctx.agents.reload()
})
```

## Apply

`apply` tells the app what a change needs:

| Value | Means |
| - | - |
| `live` | The extension reads the value when it needs it. Nothing else happens |
| `agent-rebuild` | The agent is built again with the new value: use it for a setting that [catalog transforms](https://zidane.si/docs/extensions-catalogs.md#transforms) or a provider factory read |
| `restart` | The app must restart. Settings marks the row |

## Locked values

A config file's `extensions` entry can set values ([from a config file](https://zidane.si/docs/extensions.md#from-a-config-file)):

```json title=".zidane/config.json"
{
  "extensions": [
    { "source": "owner/kitchen-sink", "settings": { "step": 5, "flavor": "salty" } }
  ]
}
```

These values show locked in Settings, with the file that set them. `ctx.settings.locked(key)` is `true` for them, and an action's `set` rejects them. A config file cannot set a secret: the entry is refused for that key.

A value the user stores in their user or project settings is read before the config entry. Locking stops edits in Settings. It does not override a value saved earlier.

## Forms

`ctx.ui.form` takes the same schema shape to ask the user for values in a dialog ([UI](https://zidane.si/docs/extensions-ui.md)):

```ts
const values = await ctx.ui.form({
  title: 'Portions',
  schema: { items: { people: { type: 'number', label: 'People', default: 2, min: 1, max: 12 }, spicy: { type: 'boolean', label: 'Spicy' } } },
})
```

A `SettingsPage` placement draws a schema as an extra page in Settings ([placements](https://zidane.si/docs/extensions-placements.md)).

## In each app

| App | Settings | Daemon mode |
| - | - | - |
| Terminal app | An Extensions tab after the built-in tabs, with each extension and its pages, then a tab per category (labelled with `icon.tui` and the category). Extensions & Plugins keeps the list, switches and installs | Rows, values and actions come from the daemon |
| Desktop app | An entry per extension under Extensions (a top-level entry after Agent) in the settings sidebar, with its name and icon, its pages under it, then a group per category after the built-in groups. Settings › Extensions keeps the list, switches and installs | The same, from the daemon. Pages show when Settings is on the host of the session in view |
| Web app | The same as the desktop app: an entry per extension under Extensions, and Settings › Extensions keeps the list, then the locked values, catalog changes, connections and `settings.section` items under it | Always from the server |

The desktop and web apps list categories in load order, the terminal app by label.

Session-scoped values are set from the session, where the app offers it. Form dialogs check each field as the user types, and the host checks the answer again.

## Pitfalls

- A non-empty `default` hides any fallback you read later, such as an environment variable: `get` returns the default. Leave `default: ''` and apply the fallback in code, as the Bolt extension does for its base URL.
- `get` with a `session` scope needs `{ sessionId }`. Without it, the session layer is skipped.
- A stored value that fails the checks on a later read (after you narrow a range or a `pattern`) falls through to the next layer, often the default. A `pattern` that is not a valid regular expression checks nothing.
- An action's `run` has the callback timeout of 10 seconds, and `status` has 3 seconds ([guards](https://zidane.si/docs/extensions-lifecycle.md#guards)). Start longer work without awaiting it, and report through a toast.
- A setting in a section that also names another `page` is refused. Set `page` on the section instead.
- An action's default `scope` is `admin`: a web window or a script with a chat token sees the button disabled. Lower it only for actions any chat client may run.
- Renaming a key loses the users' saved values. Kebab-case keys (`record-mentions`) are valid, so keep an existing key as it is.

## Related

- [Storage](https://zidane.si/docs/extensions-storage.md): data the extension keeps on its own.
- [Integrations](https://zidane.si/docs/extensions-integrations.md): API keys and sign-ins, with accounts.
- [Catalogs](https://zidane.si/docs/extensions-catalogs.md): transforms that read settings.
