Plugin settings and internationalization
Write a plugin settings page
Use the existing host editor for ordinary plugin settings. Declare configSchema in the plugin manifest; the host renders it under Settings without a custom browser bundle.
{
"configSchema": [
{
"key": "enabled",
"label": "Enabled",
"type": "boolean",
"default": true
}
]
}
Add label and hint translations in the plugin's i18n files, and read the validated plugin configuration through PluginContext. The declared default initializes the form; the plugin must use the matching runtime default when no value is stored. Use userConfigSchema for personal settings. For a custom plugin settings page, declare the browser contribution and use the shared configuration editor described in Browser UI. MCP is a real browser/settings consumer; Files demonstrates an ordinary manifest-driven form.
Configuration fields
configSchema describes instance-wide settings. userConfigSchema
describes per-account settings. Both use the same field shape.
PluginConfigField and PluginManifest are inferred with TypeBox Static from their
runtime schemas in src/plugins/manifest.ts, rather than maintained as parallel
interfaces. CONFIG_FIELD_TYPES is the literal tuple accepted by the field schema;
project-panel placement and the user field retain their schema literals.
The browser imports these types only, so schema validation adds no browser code.
retiredConfigKeys optionally names exact instance-config keys the plugin no longer uses. For example:
{
"retiredConfigKeys": ["sec_voice", "voiceProvider", "stt", "sttModel", "tts", "ttsModel", "ttsVoice"]
}
Discord and Telegram use this list when moving voice notes to core Voice mode. On each enabled plugin load,
after its manifest and entry are accepted and before register(ctx), only the authoritative daemon removes
present listed keys through ConfigStore.update. The plugin receives the cleaned configuration immediately.
Each removed key produces one core log entry with reporting: 'local-only', without its value. Reloads with
nothing left to remove make no write or removal log. Runners never prune, even when the manifest declares
the list. Omission or an empty list leaves storage untouched.
The list is limited to 64 unique, non-empty, trimmed strings of at most 128 characters. A listed key still
declared in configSchema is a manifest error. Keys are literal properties, not paths or patterns.
Unlisted values, undeclared values, secrets and other plugins' configuration are retained. Per-account
configuration is unaffected: configSchema describes a form, not a storage allowlist. One cleanup scans
the bounded list and writes the plugin slice once against the snapshot revision. A concurrent config change
or a failed write fails that plugin load through its normal error path, preserving the newer configuration.
The supported field types are:
string, secret, boolean, number, textarea, rolePolicies, model, provider,
section, enum, multiSelect, code, prompt, json, embeddingModel,
destination, projects, plugins, tools, models, timezone, tokenList, user
mcpServers is retired in browser API 33. The manifest parser drops it from
both config forms with the same unknown-type diagnostic as every unsupported field,
preserving stored values and keeping older
installed plugins loadable. The MCP plugin's server page, API, and
p_mcp_servers store are the single supported management surface;
shared generic editor primitives remain unchanged.
Every field has key, label, and type. Optional properties are
hint, required, min, max, step, placeholder,
display, browse, default, providerType, modelKind,
options, language, help, risk, advanced, fullWidth,
and visibleWhen.
Field-specific rules:
optionssuppliesvalueandlabelpairs forenumandmultiSelect. AmultiSelectdefault is an array of unique declared option values, including an empty array. For example, declare weekday options with valuessun,mon,tue,wed,thu,fri,satand setdefault: ["mon", "tue", "wed", "thu", "fri"]; the meeting-confirm plugin uses this for calling days. Instance and account manifests reject scalar, duplicate or undeclared defaults during parsing, before installation or loading. An omitted default remains omitted. Defaults initialize the existing form only; they do not write stored settings, and the plugin owns the matching runtime fallback. Validation costs one option scan per selected value and one duplicate check.languageselects the editor mode forcode.providerTypenarrows aproviderpicker to one or more provider types.model,embeddingModelandmodelsfields hold a model exec. The host keeps the stored spelling canonical (<provider>/<model>) on both the instance-wide and the per-account form, so a plugin never has to read the legacyelowen:prefix. Amodelslist is saved only if it is a valid personal model grant:*stands alone, and no entry contains a comma.modelKind: "image"makes amodelfield use the image model catalog. Such a field is not an exec: its value is the bare id the image APIs take, and the host stores it exactly as it is.- For numbers,
display.controlisinputorslider;display.unitanddisplay.divisoraffect presentation only.isStepAligned(value, base, step)insrc/shared/pluginNumber.tsis the canonical finite-number alignment rule; callers validate the step as positive first and use the declaredmin, or zero, as the base. It accepts rounding error within eight scaled machine epsilons. Manifest defaults and slider maxima consume it; the instance and account API writers share it throughsrc/api/routes/plugins/configValues.ts, preserving finite-number, min/max and exact field-error checks. The byte-identicalweb/lib/pluginNumber.tscopy serves draft validation and display conversion without a daemon runtime import. Omitted steps impose no alignment constraint. Evaluation is constant-time, does no I/O and does not clamp values or alter the caller's bounds or error messages. - Ordinary enum fields render through the choice picker (
ChoiceFieldwithpicker="always"). An enum withdisplay: { "control": "select" }or ariskvalue renders a compactSelectMenuinstead, so long labels do not crowd the row (web/modules/settings/PluginConfigEditor.tsx:230-240). Stored values stay unchanged.selectis rejected on non-enum fields, and numeric display options are rejected on an enum select (src/plugins/manifest.ts:302-307). display.placement: "pluginForm"explicitly assigns an editable field to the plugin's own form. The generic instance and account editors omit its row and any section left with no visible inputs. The field remains in the schema, config reads, validation and revision-safe writes. Omission keeps the generic editor. This is opt-in placement, never automatic duplicate detection or an authorization boundary. Sections cannot declare it. Teams uses it forrolePolicies, whose complete ordered and wildcard policy editor lives in People. Other intentional plugin-page, account and entity settings surfaces stay available and share the same controls.browse: "directory"is valid only ontokenListin instance config.- A
tokenListdefault must be an array of trimmed, non-empty, unique strings. - A
timezonedefault must be a trimmed valid IANA timezone. userpicks one Elowen account and stores its username as a string; an empty string means none. The form shows the admin user directory (GET /users) as a dropdown with each account's avatar and name, and the config route refuses a username that does not exist at save time (one directory read per save of a schema that declares the type). A saved username whose account was later deleted stays shown and marked, and the plugin resolves it at runtime and owns the missing-account case. Because the directory is admin-only, the type is rejected inuserConfigSchema. A core that predates the type drops the field with a diagnostic and keep the stored value. meeting-confirm uses it for the account its calls run as.- Numeric bounds, defaults, and steps are validated by the host.
visibleWhendisplays a field only when another field equals the declared string, number, or boolean value.- Unknown field types are removed from the form with a warning. The stored value is preserved so a newer host can render it later. Other manifest errors reject the manifest.
Keep English labels, hints, descriptions, options, and browser strings in the manifest. They are the fallback when no translation exists.
Shared settings editor
UI API 51 publishes exact PluginConfigEditorProps: { name, detail, draft, mode?, layout? }, with mode setup | behavior | advanced | all. layout
defaults to groups; use rows inside a caller-owned entity group to render
only schema rows with the same localization, controls and draft. Filter that
entity's schema in detail, but keep the complete schema in the shared draft
so other fields and their revision are preserved. Optional model fields offer
the empty choice; required model fields do not. Its minimal
PluginConfigEditorDetail is { name, configSchema, secretsSet, i18n? };
i18n maps locale to optional fields, then field key to optional
label, hint, help and options keyed by option value. The editor
owns locale lookup, English fallback and host risk translations. Do not pass
localization callbacks. Supply the existing PluginConfigDraft, including
resolveConflict('reload' | 'merge'); secrets still use explicit
commitValue and are never returned as plaintext. An unresolved revision conflict blocks
all writes, including autosave, retry, flush and queued explicit commits. Its remote revision
is adopted only after the user chooses reload or merge. Conflict merge applies only
fields changed locally since the persisted snapshot, preserving untouched remote
updates and deletions. For personal settings, inject the existing personal-config
transport as usePluginConfigDraft's save option and retain the host query key.
Titled groups with visible inputs use defaultOpen={false} and their existing
plugin.<name>.<mode>.<section> fold key. Remembered states win and deep-link
reveal still works. Headerless fields and intentional information cards do not
fold; sections with only hidden fields produce no empty disclosure. Short
field hint remains visible; detailed help uses HelpTip. Section hint and
help are deduplicated and combined in the header HelpTip.
The existing Input accepts unit?: string: omission preserves ordinary
input rendering; unit="" reserves the same numeric unit cell. A full-width
numeric band is 18 rem: 13.5 rem input, 0.5 rem gap and 4 rem unit cell. The input
shrinks on narrow rows. className, native attributes, events and ref target
the actual input; a nonempty unit joins its accessible description without
changing its label. Divisors only scale display; bounds, precision and step
remain canonical. Explicit sliders remain sliders, with invalid saved values
falling back to the unit-aware input.
components.LimitSliderRow publishes the existing limits-dialog row and exact
LimitSliderRowProps: icon, label, valueLabel, scalar value/min/max/step and
onChange, plus optional hint, note, children, ariaLabel, disabled,
sliderClassName and siblingControl. Core limits dialogs and Chatbot limits
share it; the generic schema slider keeps its compact settings-row layout.
A minimal custom editor is:
const { PluginConfigEditor } = runtime.components;
const draft = runtime.hooks.usePluginConfigDraft(detail.name, detail);
return <PluginConfigEditor name={detail.name} detail={detail} draft={draft} mode="all" />;
Bundles using this signature, Input.unit or LimitSliderRow declare
web.requiresApiVersion: 51 in the manifest and requiresApiVersion: 51
in registration. Schema presentation changes alone need no bundle minimum
increase. Release the host before dependent bundles; no old callback adapter
is retained. requiresSharedApi remains a separate helper contract. Rendering
adds local field lookups and a schema scan, with no new request, store or endpoint.
i18n
Put optional locale files in i18n/<lang>.json. The loader builds a locale map
from the files it can parse. A locale may contain description, label,
userConfigLabel, and fields, where each field key may override label,
hint, help, and option labels. Both instance configSchema and per-account
userConfigSchema forms resolve fields.<key>.help in the current locale and
fall back to the field's English manifest help when the override is absent.
Long-form help stays behind the shared help affordance and is omitted when it
is blank or repeats the localized one-line hint. For example, a GitHub
mergeMethod field can supply fields.mergeMethod.help in i18n/cs.json;
without it, the same form renders the manifest text. Resolution is a local
field lookup and performs no additional request.
Its alerts map overrides manifest alert templates
by key. Its web object may override nav, account, user, project,
settings, and the flat strings map. The English manifest and web metadata
remain the source and fallback. Missing or malformed locale files do not stop
the plugin; the affected text remains in English.
The manifest's top-level alerts map contains the plugin's English alert
templates. Alert producers pass a title key, optional subtitle and body keys,
optional facts (up to eight { labelKey, valueKey } template pairs shown as a
labelled list), and tagged parameters through ctx.alerts. Numbers, { kind: 'bytes', value } and { kind: 'usd', value } parameters are formatted for the
recipient's locale. The host validates every key and placeholder and renders
the message for each recipient's locale when the bell or push payload is read;
push receives the same text as plain lines.
The instance configuration API is administrator-only. The host exposes plugin
listing and detail, live contribution information, configuration, enablement,
logs, and data-management routes under the /plugins route family. The
schema form reads a masked snapshot and its revision, then sends
expectedRevision with the next patch. Omitted keys remain unchanged.
null clears an optional non-secret value. A stale revision returns
409 with the canonical current snapshot. A successful instance config
write is durable before the restart that applies it: the response is 202 with
pending: true, plus a restart block when this daemon can restart itself.
Secret fields are write-only. The host returns only the names of secrets that
are set. Omitted and empty ('') secret values keep the existing secret; an
explicit null erases it, also for a required field, which then reads as
missing setup. The core form never puts a secret in its autosaved snapshot: an
unset secret saves on blur or Enter, a stored one shows only a Replace action,
and replacing it with nothing sends that null. Escape cancels a replacement.
This applies to configSchema and userConfigSchema alike.
For per-account settings, use userConfigSchema, userConfigLabel, and
optionally userConfigPlacement: "account" | "pluginPage". Read values at
runtime with ctx.userConfig(). It returns null when the current turn is
not acting as an account and never falls back to instance configuration.
Per-account values use the same revision and patch rules. The host routes are
GET /plugins/user-config and PATCH /plugins/:name/user-config. Secret
values never leave the daemon.