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:
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:
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), 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#
| 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) |
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.
| 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.
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 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). 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#
| 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) |
get reads the layers in this order, and the first that has a valid value wins:
session, withsessionIdand when the setting allows that scopeprojectuser(for a secret: the auth store)- A config file's entry (locked values)
- 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.
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 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):
{
"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):
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#
| 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
defaulthides any fallback you read later, such as an environment variable:getreturns the default. Leavedefault: ''and apply the fallback in code, as the Bolt extension does for its base URL. getwith asessionscope 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. Apatternthat is not a valid regular expression checks nothing. - An action's
runhas the callback timeout of 10 seconds, andstatushas 3 seconds (guards). Start longer work without awaiting it, and report through a toast. - A setting in a section that also names another
pageis refused. Setpageon the section instead. - An action's default
scopeisadmin: 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: data the extension keeps on its own.
- Integrations: API keys and sign-ins, with accounts.
- Catalogs: transforms that read settings.