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 instance form; instance runtime consumers use the matching runtime default when no value is stored. Per-account runtime values already resolve manifest defaults through ctx.userConfig(). 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,
credential 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. Instance defaults initialize the existing form; account defaults also resolve at runtime throughctx.userConfig(); 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. - Every enum field renders through the one anchored dropdown (
ChoiceField, theenumcase inweb/modules/settings/PluginConfigEditor.tsx);display.controlandriskdo not select a different control,riskonly adds its badge. Stored values stay unchanged.selectis rejected on non-enum fields, and numeric display options are rejected on an enum select (parseManifestinsrc/plugins/manifest.ts). 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; an empty string is also accepted. 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.
Manifest patch and replacement validation
applyConfigPatch(schema, stored, values, brainModelGate?, destinationPlatforms?, usernames?, operation = 'patch') is the single host helper for manifest field validation and reset/secret rules. The real bulk caller supplies 'replace'; per-plugin/account PATCH callers use the default. Replacement starts with supplied values, preserves supplied undeclared keys and removes omitted keys. PATCH starts with stored values and preserves omitted keys. Explicit null clears optional overrides and secrets according to the existing required-field contract; a blank secret retains the stored secret in either mode. Replacement validates all supplied declared fields, while PATCH retains its existing unchanged-value contract. No caller adds a second schema validator. Without a supplied plugin slice neither route invokes this helper for that plugin.
Scalar configuration normalization
Import { normalizeConfigScalars } from 'elowen-plugin-shared/configScalars'. Its contract is normalizeConfigScalars(source: Readonly<Record<string, unknown>>, booleanDefaults: Readonly<Record<string, boolean>>, enumOptions: Readonly<Record<string, readonly [string, ...string[]]>>): Record<string, unknown>. Minimal use is normalizeConfigScalars(config, { showTools: true }, { mode: ['live', 'final'] }). It returns a shallow copy and processes only scalar overrides whose source value is not undefined. Missing/undefined values keep inheritance intact, valid booleans and declared enum strings remain unchanged, invalid present booleans select the supplied manifest default, and invalid present enum values select the first nonempty option. Unlisted values and nested references are retained. Callers provide the already-object source and their own domain defaults/options; this helper does not parse unknown input or own roles, token lists or platform policy. It has no IO or registration cost. Current validation is tests/shared/configScalars.test.ts; no production caller uses this helper yet.
Shared settings editor
UI API 51 publishes exact PluginConfigEditorProps: { name, detail, draft, mode?, layout? }, with mode setup | behavior | advanced | all | credentials.
A host plugin detail carries credentials; then every mode except
credentials omits those fields (see
Credentials and the Access page). 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, secretsUnreadable?, i18n? };
secretsUnreadable names stored secrets the daemon can no longer decrypt, which the editor marks
as unreadable instead of stored, keeping the Replace action.
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. Every save a draft sends carries only the
fields whose value changed since the last persisted snapshot, and a committed value goes out even
when unchanged: a field the editor merely DISPLAYS — an account default the account inherits, a
legacy value nobody touched — is never written back as an edit of its own. Conflict merge applies
only fields changed locally since the persisted snapshot, preserving untouched remote updates and
deletions. A draft with no unsaved edit adopts a newer revision and
configuration that another editor of the same plugin stored (Settings → Access
next to a plugin page, for example); a draft with an edit keeps its revision
and meets the conflict choice on save. 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 start open and keep 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 is the host's SliderWithReading with an unbounded reading, so a long unit wraps under the track.
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.
Credentials and the Access page
Settings → Access is the one administrator page where every plugin's instance credentials are entered: API keys, tokens and passwords, plus the few plain fields they cannot work without, such as an account SID next to its auth token. A plugin appears there only by declaring it in its manifest; core knows no plugin names.
Every type: "secret" field of configSchema is a credential. Mark a non-secret
companion with credential: true:
{
"configSchema": [
{ "key": "sec_connection", "label": "Connection", "type": "section" },
{ "key": "accountSid", "label": "Account SID", "type": "string", "required": true, "credential": true },
{ "key": "authToken", "label": "Auth token", "type": "secret", "required": true },
{ "key": "region", "label": "Region", "type": "enum", "options": [{ "value": "us1", "label": "US" }] }
]
}
Where and when it acts:
- The daemon derives the classification once, with
credentialFieldKeysinsrc/shared/pluginCredentials.ts.GET /pluginsandGET /plugins/:namereturn it ascredentials: { keys, corrupt }: the credential keys in schema order and the secret keys the encrypted vault reports corrupt, read from the vault's corruption index without decrypting anything. - Settings → Access lists every enabled plugin with
credentialsas one foldable row, open by default and remembered per plugin: icon, name and status (connected, required details missing, reconnect needed, not set). Expanding it renders exactly those fields through the shared editor (mode="credentials"), grouped by their declared sections when they span more than one. Secrets keep the write-only commit on blur or Enter; companions autosave with the plugin's revision-safe draft throughPATCH /plugins/:name/config. - Every other editor mode leaves the credential fields out, and a section left
without inputs disappears.
setupandallshow an Access card instead, with the status and a link to/settings?cat=access&row=access.<plugin>, which opens that row expanded. This applies wherever the host detail is passed to the editor, including a plugin's own settings page; Microsoft Teams' workspace Settings tab is a real consumer.
What happens without a declaration: a plugin with no secret and no marker gets
no row, its editor renders as before, and its listing entry has no
credentials.
Limits: the marker is accepted only in configSchema, only as true, and only
on non-secret editable fields; userConfigSchema rejects it, because personal
credentials stay on the account form. Required credentials count toward the
Access status, not toward the plugin detail's setup checklist. Storage, masking,
revisions and restart behavior are those of ordinary config fields and secrets.
Cores without this seam ignore the marker, so a plugin may declare it without
raising requiresCore. Cost: one plugin detail read per credential row when
Settings → Access opens; no new endpoint or store.
Real consumer: Twilio marks accountSid and apiKeySid next to its
apiKeySecret and authToken.
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 Raynet
username field can supply fields.username.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 resolves each non-secret field from the account's own value,
then the account default row under ACCOUNT_DEFAULTS_USER_ID = 0, then the manifest default.
It returns null when the current turn is not acting as an account. A secret stays personal and
never resolves from the default row or a manifest default; chatbot accounts skip the default row.
This does not read the instance's plugins.config slice. For example, Raynet's useOwnCredentials
resolves to its manifest's false unless an account default or an own value turns it on.
Administrators edit these defaults through GET /users/defaults/plugins and
PATCH /users/defaults/plugins/:name with { values, revision }; secrets are rejected.
POST /users/defaults/apply with plugin:<name> clears human accounts' non-secret overrides
and advances their revisions. GET /plugins/user-config reports effective values.
These are synchronous small-blob reads, with no model cost or plugin reload.
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.