Skip to content

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. For accounts and sign-ins, use integrations.

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:

TypeScript
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:

TypeScript
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
FieldDoes
pagesOrdered 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[].pageThe page a section sits on. Default: the first page
page on a settingThe 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), 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 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#

FieldTypeDoes
type'boolean' | 'number' | 'string' | 'enum' | 'model' | 'action'Required. The kind of setting
labelstringRequired. The row's label
descriptionstringHelp text under the label
sectionstringThe id of a section in sections
pagestringThe id of a page in pages, for a setting outside any section (pages)
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)

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.

FieldDoes
defaultThe value while unset
placeholderGrey text in the empty field
multilineA text area
patternA regular expression the value must match
validate(value)An extra check. Return an error message to refuse the value
secretThe 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.

TypeScript
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
FieldDoes
buttonRequired. 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
scopeThe 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:

MemberDoes
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 key, or a refused value
signalAborts 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). 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 does with its config reader.

Read values#

MemberDoes
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)

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)
  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.

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

Apply#

apply tells the app what a change needs:

ValueMeans
liveThe extension reads the value when it needs it. Nothing else happens
agent-rebuildThe agent is built again with the new value: use it for a setting that catalog transforms or a provider factory read
restartThe app must restart. Settings marks the row

Locked values#

A config file's extensions entry can set values (from a config file):

.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):

TypeScript
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).

In each app#

AppSettingsDaemon mode
Terminal appAn 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 installsRows, values and actions come from the daemon
Desktop appAn 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 installsThe same, from the daemon. Pages show when Settings is on the host of the session in view
Web appThe 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 itAlways 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). 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.
  • Storage: data the extension keeps on its own.
  • Integrations: API keys and sign-ins, with accounts.
  • Catalogs: transforms that read settings.