Admin: plugins and models #
Summary #
Admins add, configure, reorder, disable, and remove plugins in the admin area, and manage the models everyone can use with the admin's keys. The plugins an admin can choose from are the ones installed with the server, listed as dependencies in the root package.json. Each configured plugin is a plugin instance stored in the database, with its options validated against the plugin's optionsSchema and its secret options encrypted. The admin form for an instance is generated from that same schema, so most plugins need nothing new to get one. A saved change takes effect without a restart: the server builds a new plugin runtime from the new settings, switches new turns over to it, and lets turns already running finish on the old one.
This builds on [[admin]] and replaces the plugins and models sections of config.yml. [[admin-settings]] covers the remaining settings.
Motivation #
An admin shouldn't need a shell and a restart to add a search engine, rotate a provider key, or offer a new model. Plugins already describe their options with zod schemas, and the web app already turns zod schemas with JSON Forms rules into user settings forms, so the admin forms come almost for free.
Installing plugin packages from the web UI would let anyone who takes over an admin account run arbitrary code on the server, so installation stays with the image: a plugin is available when it is a dependency, and configured when an admin adds it.
A restart on every save would drop every reply being streamed. Rebuilding the plugin runtime in the running process avoids that.
Design #
Installed plugins #
A plugin package is installed when it is listed in the dependencies of the root package.json and its own package.json has the keyword scn-chat-plugin. Every shipped plugin gets the keyword. Packages are resolved from the repository root. A fork adds a plugin with pnpm add, or with a workspace: or file: dependency for a local one. Local ./ specifiers are no longer supported, since a dependency covers them.
apps/server/src/plugins/installed.ts lists the installed plugins at startup, importing each package once. Each entry has the package name, the description and version from its package.json, and the JSON schema of its optionsSchema, from z.toJSONSchema. A package without optionsSchema gets an empty schema and takes no options. A package can be added once, unless it exports multipleInstances = true, as the OpenAI-compatible plugin does for each endpoint. Adding a second instance of any other package is refused with " is already added, and can only be added once". A dependency with the keyword but no default export factory is logged as an error and left out.
Plugin instances #
plugin_instance (
id text primary key, -- the plugin's ID, or one a site config chose
package text,
position integer,
enabled integer,
options_json text, -- options without secret fields
secrets_encrypted text, -- secret fields, encrypted
created_at, updated_at, updated_by
)
- Secrets. Top-level option fields marked
.meta({ secret: true })are stored insecrets_encrypted, with the sameSecretBoxas user keys. The API never returns them. It returns the names of the secret fields that have a value. On save, a blank or missing secret keeps the stored value, and a field listed inclearSecretsis removed. Every shipped plugin marks itsapiKeyoption as secret. Secrets that no current or retired key can decrypt read as unset, so the server still starts and the plugin loads or fails as it would without them. The API reportssecretsUnreadablefor that instance, and its secret fields say their saved value can't be read. The ciphertext is written back unchanged until the admin enters a secret or ticks Clear on any secret field, which discards it. - Order.
positionis the load order, which decides hook order as the plugins spec describes. - Instance IDs. An instance added in the admin area takes the ID of the plugin it makes, such as
web-searchoropenai-compatible-litellm, and adding one whose plugin ID an instance already has is refused. Instances from before this, under random UUIDs, are renamed to their plugin's ID at startup, keeping their options and secrets. One whose options no longer load, or whose plugin ID another instance has, keeps its UUID, and the log says why. Nothing else stores instance IDs, so renaming is safe. - Disabled instances keep their options and aren't loaded.
- Plugin IDs. The factory decides the plugin's ID, as it does today, and some plugins derive it from their options, like
openai-compatible-<id>. User settings, tool switches, provider IDs in model references, and user credentials all key on those IDs, so an edit that would change an instance's plugin ID is refused with "This change would rename the plugin from X to Y. Add a new instance instead." - Deleting an instance leaves its users' settings and tool switches in place, so adding the same plugin back restores them.
Plugin runtime #
A plugin runtime is everything built from the plugin instances: the plugin host with its registries and hooks, and the model catalog. apps/server/src/plugins/runtime.ts holds the current one:
interface RuntimeHolder {
current(): PluginRuntime
acquire(): { runtime: PluginRuntime; release(): void }
rebuild(instances: PluginInstance[]): Promise<void>
}
- Readers. Request handlers call
current()once per request, and never keep a runtime between requests. Routes that read the host, providers, ingesters, hooks, or catalog change to take the holder instead of a fixed host. - Turns call
acquire()when they start andrelease()when they end, and use that runtime throughout, so a turn never mixes plugins from two runtimes. That includes theturn:afterhooks it runs. - Rebuilding imports and validates each enabled instance's options, calls each factory, checks the plugins, and runs each
setupagainst a fresh host, in position order. It then swaps the new runtime in. The old runtime'sclose(), which runs its plugins'onClosehandlers, waits until every turn that acquired it has released it. - One at a time. Rebuilds are serialized. A save waits for any rebuild already running.
- Context.
ctx.app.namereads the current app name on each access, so renaming the app doesn't need a rebuild.
Saving #
Every change to plugin instances, whether adding, editing, reordering, enabling, disabling, or deleting, goes through one path, one change at a time:
- Build a candidate runtime from the full proposed instance list, the same way startup does, recording each instance that fails.
- Refuse the change, with 400 and nothing changed, when the changed instance failed, or an instance that is loaded now would fail, or a loaded instance's plugin ID would change. Invalid options come back with
{ error: 'InvalidRequest', issues }, with issue paths pointing at option fields. Refinements that span fields, like web search requiring a key for Brave, come back the same way. A factory orsetupthat throws, a duplicate plugin ID, a duplicate provider, tool, or ingester, or an unsupported plugin API version comes back as the message. - Check the admin models against the candidate, as described below. A model left without its provider refuses the change.
- Write the instances in one transaction, then swap the candidate in. A refused candidate is closed.
Startup #
At startup the runtime is built from the stored instances with the same steps, but one broken instance doesn't stop the server, since the admin area is how it gets fixed. An instance whose package is no longer installed, whose stored options fail the schema, or whose factory or setup throws is skipped. It is logged as an error and shown in the admin area with its error. Everything a failed plugin registered before it threw is discarded, so each plugin's setup runs against a staging context whose registrations are committed only when it finishes. Duplicate IDs keep the first instance by position and fail the rest.
Admin models #
The models section of config.yml moves to the database:
admin_model (
provider text, model_id text,
name text,
capabilities_json text,
roles_json text,
is_default integer,
position integer,
created_at, updated_at, updated_by,
primary key (provider, model_id)
)
- Validation on save. The provider must be registered in the current runtime and have an admin key. Every role must exist, and
rolescan't be empty, so who can use a model stays a deliberate choice. Marking a model default clears the flag on every other model. - Reading.
ModelCatalogreads admin models from the database on each call, so a model change needs no rebuild. The role check is unchanged. - Models whose provider isn't loaded, because its instance was skipped at startup, are hidden from users and shown in the admin area with a warning.
- Picking models. Admin models are managed inside their provider's plugin block, the way users pick models for their own keys. A Refresh button next to the provider's key and base URL fetches its model list, and the admin ticks the models to offer and sets each one's name, capabilities, and roles. Refresh works on values not saved yet: the server builds a temporary copy of the plugin from the form's options, with a blank secret meaning the stored one, runs its
setupagainst a scratch context, and calls the provider'slistModelswith no arguments, which uses the plugin's own key and base URL. The copy is closed afterwards. A provider withoutlistModelsgets a field to type a model ID instead. - Instance changes. A plugin change that would leave a model without its provider, by deleting or disabling the instance or removing its admin key, is refused, naming the models.
- Role changes. A role that a model names can't be deleted, as [[admin]] describes.
- Order.
positionis the order ofGET /api/models.
Admin API #
| Route | Purpose |
|---|---|
GET /api/admin/plugins/installed |
Installed plugin packages, with description, version, and options schema. |
GET /api/admin/plugins |
Plugin instances in order, each with its ID, package, plugin ID and name when loaded, enabled flag, options without secrets, the names of secret fields that are set, the options schema, the providers it registered with their model defaults, and its status, loaded, disabled, or failed with the error and issues. |
POST /api/admin/plugins |
Add an instance from { package, options }, at the end. |
PUT /api/admin/plugins/:id |
Change { options, clearSecrets, enabled }. |
DELETE /api/admin/plugins/:id |
Remove an instance. |
PUT /api/admin/plugins/order |
Reorder from { ids }, which must list every instance. |
GET /api/admin/models |
Admin models in order, with a warning on any whose provider isn't loaded. |
POST /api/admin/models, PUT /api/admin/models, DELETE /api/admin/models |
Add, change, and remove admin models. The provider and model ID are in the body. |
PUT /api/admin/models/order |
Reorder from a list of { provider, id }. |
PUT /api/admin/providers/:id/defaults |
Set a loaded provider's model defaults from { vision, reasoning, tools }. |
POST /api/admin/plugins/list-models |
A provider's models from { package, instanceId?, options }, using the form's options, with blank secrets taken from the instance. |
Admin area #
Two sections join the admin sidebar:
- Plugins (
/admin/plugins). The instances in load order, each with its name linking to its page, its package, its status and error, and up and down buttons, then an "Add plugin" link. - A plugin's page (
/admin/plugins/:id). A link back to the list, the plugin's status and error, the options form generated from the schema withSchemaFields, an Enabled checkbox, Save, and "Remove plugin", which asks to confirm and then goes back to the list. Each plugin saves on its own, since each save rebuilds the runtime and can fail on its own. Secret fields show "set" when a value is stored, and a "Clear" checkbox. - Adding a plugin (
/admin/plugins/new). A link back to the list, a picker of the installed packages that aren't added yet or allow more than one instance, the chosen package's description and form, and Add, which opens the new plugin's page. - Models on a provider's page. A provider plugin's page shows a list of its admin models below the options form, with each one's name, ID, capabilities, roles, and whether it's the default, and under it a "Default capabilities" row of Vision, Thinking, and Tools chips, which sets the provider's model defaults and saves as each is pressed, then the models to tick, a field to type a model ID, and the Refresh button. Each model links to its own page (
/admin/plugins/<id>/model?provider=…&id=…), which edits its name, capabilities, and roles and removes it once the admin confirms. Models save through the models routes, separately from the plugin's options. - Models (
/admin/models). The admin models from every provider in one list, for picking the default and setting the order across providers, with a link to each model's provider block.
Admin option forms support the same JSON Forms rules as user settings. Web search adds rules to its options, showing apiKey for engines that need a key and baseURL for SearXNG.
Plugin API changes #
-
The Anthropic, OpenAI, and Google plugins gain
listModels, from each API's model list endpoint, so Refresh works for them.keyedProvidertakes an optionallistModels(apiKey)for this. The model lists don't report capabilities, so new models start with the provider's model defaults. -
docs/plugins.mddocuments thescn-chat-pluginkeyword, that options are edited in the admin area,.meta({ secret: true })on options, and JSON Forms rules on options. -
PLUGIN_API_VERSIONstays 1. Existing plugins keep working, and only need the keyword and secret markers.
Scope Boundaries #
- No installing, updating, or removing plugin packages from the admin area.
- No per-model limits, quotas, or usage reporting.
- No nested secret options. Only top-level fields can be secret.
- No plugin-specific admin screens. Every admin form is generated from
optionsSchema. - No undo or history for plugin and model changes.
- No live reloading of plugin code. A new plugin version needs a new image.
Edge Cases and Decisions #
- Options are validated without the schema's own check for unknown keys, so an option a plugin drops in an upgrade is ignored rather than breaking the plugin, and the admin form leaves it out on the next save.
- Saving is strict and startup is lenient. A save that would break a plugin is refused, but a plugin that breaks at startup, for example after an upgrade, is skipped so the admin area stays reachable to fix it.
- A plugin ID can't change through an edit, because records and user data refer to it.
- Turns acquire one runtime for their whole run, so a save mid-turn never changes the tools or hooks a turn is using.
- Each plugin has its own page, so a long list of plugins doesn't become one long form.
- Most plugins register fixed IDs, so a second instance could only fail. A package opts in to more than one instance with
multipleInstances. - Each instance saves on its own. A combined save would fail as a whole on one bad plugin and leave the admin guessing which one it was.
- Admin models are read from the database on each call, like roles, so they need no rebuild.
- Deleting an instance keeps user settings, so removing and re-adding a plugin loses nothing.
- Installed plugins come from the root
package.json, not from scanningnode_modules, which is unreliable under pnpm. - An instance that is already broken, such as one whose package was removed, doesn't block saving the others. A save is only refused for what it breaks.
- Model routes take the provider and model ID in the body, since model IDs like
meta-llama/Llama-3contain slashes. - A model offered from the provider's list starts with the
userrole and the provider's model defaults, which the admin can change before or after.
Acceptance Criteria #
Installed plugins:
Instances and secrets:
Runtime:
Startup:
Models:
Admin area:
Files #
apps/server/src/plugins/installed.ts,apps/server/src/plugins/instances.ts,apps/server/src/plugins/runtime.tsapps/server/src/plugins/host.ts,apps/server/src/plugins/import.ts,apps/server/src/plugins/routes.tsapps/server/src/providers/catalog.ts,apps/server/src/providers/admin-models.ts,apps/server/src/providers/routes.tsapps/server/src/admin/routes.tsapps/server/src/turns/runner.ts,apps/server/src/blobs/routes.ts,apps/server/src/storage/routes.tsapps/server/src/server.ts,apps/server/src/config.tsapps/server/src/db/migrations/0008_plugin_instances.tsapps/web/src/routes/_app/admin/plugins/,apps/web/src/routes/_app/admin/models.tsxapps/web/src/features/admin/pages/PluginsAdmin.tsx,PluginAdmin.tsx,NewPluginAdmin.tsx,ModelsAdmin.tsx,apps/web/src/features/admin/hooks/changes.ts,apps/web/src/shared/schema-fields/SchemaFields.tsxpackages/plugin-api/src/index.ts, everyplugins/*/package.json,plugins/anthropic/,plugins/openai/,plugins/google/,plugins/openai-compatible/,plugins/web-search/src/index.tspackage.json,docs/plugins.md