ADR 0019 — Plugins contribute config, settings & secrets
- Status: Accepted (2026-06-04)
- Date: 2026-06-04
- Deciders: Josh Mabry; protoAgent maintainers
- Tags: extensibility, plugins, config, settings, secrets, fork, architecture
- Related: completes the plugin reach started in ADR 0001 (tools+skills) and ADR 0018 (surfaces/routes/subagents); prerequisite for migrating the Discord/Google surfaces to plugins (#509).
Accepted. ADR 0018 let a plugin contribute a surface/route/subagent, but a configurable surface (a Discord-style gateway) still needs core edits for its config — the
discord_*/google_*dataclass fields,SECRET_PATHS, the Settings-schema group. So a fork's own ingress is half a plugin. Close the gap: a plugin declares its config in the manifest and gets a typed config section + secrets routing + a Settings group, with noconfig.py/config_io.py/settings_schema.pyedit.
1. Context & Problem statement
LangGraphConfig.from_yaml parses a fixed set of sections into a flat dataclass; SECRET_PATHS (a constant) routes named keys to the untracked secrets.yaml; settings_schema.FIELDS (a constant) drives System → Settings. All three are closed — a plugin can't add to them, so a configurable plugin must edit core.
The ordering constraint is the crux: config/secrets/settings must be known before a plugin's register() code runs (config is loaded at boot; secrets are stripped on every save; the settings schema is built on demand). So plugin config can't be declared imperatively in register() — it must be data, available at manifest-discovery time without importing the plugin.
2. Decision
A plugin declares its config in protoagent.plugin.yaml (parsed at discovery, no import):
config_section: discord # top-level YAML section (default: the plugin id)
config: # defaults for that section
enabled: false
admin_ids: []
secrets: [bot_token] # keys in the section routed to secrets.yaml
settings: # Settings-schema fields (System → Settings group)
- { key: enabled, label: "Enable", type: bool }
- { key: bot_token, label: "Bot token", type: secret }
- { key: admin_ids, label: "Admin IDs", type: string_list }A plugin claims a top-level section (not a nested plugins.<id> bag) — this matches how Discord/Google already store config (discord: / google:), so the migration (#509) is a lift, not a config move. The loader rejects a section that collides with a built-in (logged + skipped).
Wiring:
- Config.
LangGraphConfiggainsplugin_config: dict[section → dict].from_yamlreads each discovered plugin section, overlays the YAML on the manifest defaults, and resolves secrets from the overlay. The plugin reads its own config viaconfig.plugin_config["<section>"](passed to its surface/route). - Secrets.
config_io.secret_paths()returns the baseSECRET_PATHSplus each plugin's(section, secret_key)pairs;split_secret_updates/strip_secrets_from_docuse it, so a plugin secret is stripped tosecrets.yamlexactly like the model API key.config_to_dictincludes plugin sections (secrets redacted). - Settings.
build_schemaappends each plugin's declared fields under a group named for the plugin (keys namespaced<section>.<key>), so they render + save through the existing generic Settings surface — no per-plugin UI code.
The wizard step is out of scope (deferred) — Settings + a docs link is enough; a schema-driven generic wizard step can come later.
3. Consequences
- A fork ships a fully self-contained configurable surface as a plugin — config + secrets + Settings + (ADR 0018) surface/routes/tools — with zero core edits. Closes the last extensibility gap.
- The config + config_io + settings_schema layers gain a dependency on plugin manifest discovery (data-only — no plugin import), so the closed constants become "base + plugin-declared". Discovery is cached; manifests are pure YAML.
- A plugin section that collides with a built-in is rejected — built-ins win.
plugins.enabledchanges still need a restart (config sections are resolved at boot) — consistent with ADR 0018.
3a. Addendum — managed MCP servers + host additions (#509)
Migrating the Google surface to a plugin needed one capability ADR 0018's contribution set didn't cover: the agent reaches Google through a managed MCP server, injected into MCP discovery based on live config (OAuth-gated, started only once a token exists). Added under this ADR:
register_mcp_server(factory)— a plugin contributes a factoryfactory(config) -> entry | None, called at every graph build with the liveLangGraphConfig. A returned entry is injected like a configuredmcp.serversentry (and replaces a same-named one); its presence activates MCP even whenmcp.enabledis off. Plugins now load before MCP discovery so their factories are available (MCP tools are namespaced<server>__<tool>, so the earlier collision set is unaffected).- Generic frozen entrypoint — the google-specific
--mcp-googleshim became--mcp-plugin <id>, which imports the named plugin's module and calls itsmcp_main(). No core reference to any specific plugin remains. - Host additions —
host.config()(the live config) +host.apply_settings( patch)(persist + reload) so a plugin route can read live config and apply a change (Google's Connect flow flipsenabledand reloads). The desktop sidecar bundles theplugins/tree so plugins load in the frozen app.
4. Alternatives considered
- Imperative
register_config()inregister(). Rejected — config/secrets/ settings are needed beforeregister()runs (boot/save/schema time); a plugin's module may even need its config to import. Manifest data is available at discovery, ahead of any import. - Namespaced
plugins.config.<id>bag instead of a claimed top-level section. Cleaner isolation, but changes where Discord/Google store config (breaks the migration + existing configs) and needs nested secret-path handling. The claimed top-level section matches the status quo and keeps secret paths flat. - A typed dataclass field per plugin (generated). Rejected — can't generate dataclass fields for unknown plugins at import time; the
plugin_configdict is the honest shape for open extension.