Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.0 KiB
PRD 0052: User-defined agent provider plugins
- Status: Draft
- Author: claude
- Created: 2026-06-04
Summary
The get_provider() registry in bot_bottle/agent_provider.py is a closed list —
only "claude" and "codex" are valid templates, validated at manifest-load time and
again at launch. Users who want to run a different agent (Gemini, Aider, a custom
local model wrapper) cannot add a provider without forking the package.
This PRD opens the registry to user-defined plugins. A plugin placed at
~/.bot-bottle/contrib/<name>/agent_provider.py is discovered and loaded at launch
time. The manifest accepts any non-empty template string that names a built-in or
resolves to a user plugin at that path. No changes to the built-in providers or the
internal bot_bottle/contrib/ layout.
The preceding commit on this PR moves codex_auth.py from bot_bottle/ into
bot_bottle/contrib/codex/ — a clean-up that fits naturally here since this PR
also clarifies that contrib/ is the per-provider home.
Problem
Users building unconventional setups hit a hard wall: the template validation in
manifest_agent.AgentProvider.from_dict rejects any string not in PROVIDER_TEMPLATES.
There is no escape hatch short of editing bot-bottle's source.
PRD 0050 moved provider logic into contrib/ specifically so a third provider would
be "cheap to add" — but "cheap" today still means a pull request against the bot-bottle
repo, not a drop-in file in the user's home directory. The filesystem layout is already
the right shape; the discovery step is missing.
Goals / Success Criteria
- A user places
~/.bot-bottle/contrib/<name>/agent_provider.py— a file that exports a class inheritingAgentProvider— setsagent_provider.template: <name>in a bottle's frontmatter, and launches a bottle using that provider with no changes to the bot-bottle source. - The manifest validator accepts any non-empty template string. Unknown templates that
resolve to no user plugin still raise a clear error, but at launch (via
get_provider) rather than at manifest-load time. - Built-in provider knobs (
auth_token→ claude only;forward_host_credentials→ codex only) are guarded to built-in template names. Bottles using a user provider may set neither knob. get_provider(template)checks~/.bot-bottle/contrib/<template>/agent_provider.pybefore the built-ins, so a user can shadow a built-in for local testing.- A clear
ValueErroris raised if the user plugin file exists but contains noAgentProvidersubclass.
Non-goals
- Packaging or distributing user plugins as installable Python packages.
- A plugin registry, index, or discovery beyond the filesystem path convention.
- Adding a third built-in provider.
- Changing the
AgentProviderABC contract — user plugins implement the same abstract methods asClaudeAgentProviderandCodexAgentProvider. - Validating that user plugin images, Dockerfiles, or commands exist before launch (same policy as built-ins).
- Sandboxing user plugin code — plugins run with full Python interpreter access.
Scope
In scope
get_provider(template: str) -> AgentProvidergains a_load_user_plugin(template)step that checks~/.bot-bottle/contrib/<template>/agent_provider.pyfirst, then falls through to the built-in look-ups._load_user_pluginusesimportlib.util.spec_from_file_locationto load the module and returns the firstAgentProvidersubclass found in its__dict__. RaisesValueErrorif the file exists but exports no subclass.manifest_agent.AgentProvider.from_dict: thetemplate not in PROVIDER_TEMPLATEScheck is removed; the two built-in-specific knob guards (auth_token→ claude,forward_host_credentials→ codex) are tightened totemplate in PROVIDER_TEMPLATESso they are skipped for user-defined names.PROVIDER_TEMPLATESremains inagent_provider.pyas the set of built-in names for use by tests and any enumeration callers.- Unit tests for the discovery path:
- Plugin found and loaded → correct
AgentProviderinstance returned. - Plugin file exists but exports no subclass →
ValueError. - Unknown template with no user plugin →
ValueErrorfromget_provider. - Built-in template name still works normally even when no user plugin exists.
- Plugin found and loaded → correct
- One paragraph added to
README.mdunder a new "Custom providers" section describing the~/.bot-bottle/contrib/<name>/agent_provider.pyconvention and pointing at the existing contrib providers as reference implementations.
Out of scope
- Hot-reloading plugins during a running session.
- Plugin versioning or dependency declaration.
- Changes to smolmachines or Docker backend provisioning paths.
Proposed design
Discovery in get_provider
import importlib.util
def get_provider(template: str) -> AgentProvider:
user_plugin = _load_user_plugin(template)
if user_plugin is not None:
return user_plugin
if template == PROVIDER_CLAUDE:
from .contrib.claude.agent_provider import ClaudeAgentProvider
return ClaudeAgentProvider()
if template == PROVIDER_CODEX:
from .contrib.codex.agent_provider import CodexAgentProvider
return CodexAgentProvider()
raise ValueError(f"unknown agent provider template: {template!r}")
def _load_user_plugin(template: str) -> AgentProvider | None:
plugin_path = (
Path.home() / ".bot-bottle" / "contrib" / template / "agent_provider.py"
)
if not plugin_path.exists():
return None
spec = importlib.util.spec_from_file_location(
f"_user_contrib_{template}.agent_provider", plugin_path
)
if spec is None or spec.loader is None:
raise ValueError(f"user plugin at {plugin_path} could not be loaded")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
for obj in vars(mod).values():
if (
isinstance(obj, type)
and issubclass(obj, AgentProvider)
and obj is not AgentProvider
):
return obj()
raise ValueError(
f"user plugin at {plugin_path} defines no AgentProvider subclass"
)
Manifest validation change
In manifest_agent.AgentProvider.from_dict, remove the hard rejection:
# Before
if template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.template {template!r} "
f"is not one of {', '.join(sorted(PROVIDER_TEMPLATES))}"
)
# After — removed entirely; get_provider() raises at launch for unknown names
Guard the built-in knob checks with template in PROVIDER_TEMPLATES:
if auth_token and template == "claude": # unchanged
...
if auth_token and template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.auth_token is only "
f"supported for built-in templates ({', '.join(sorted(PROVIDER_TEMPLATES))})"
)
if forward_host_credentials and template == "codex": # unchanged
...
if forward_host_credentials and template not in PROVIDER_TEMPLATES:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
f"is only supported for built-in templates"
)
Open questions
- Shadow order. This PRD puts user plugins before built-ins, allowing local overrides. If the preference is built-ins-first (to prevent accidental shadowing), swap the order and document accordingly.
BOT_BOTTLE_CONTRIB_DIRenv var. Omitted for now —~/.bot-bottle/contrib/is consistent with the rest of the user config layout. Revisit if the need surfaces.
References
- PRD 0050 — agent provider contrib (established
contrib/as the per-provider home) - PRD 0048 — SSH deploy key provisioning (the
contrib/convention) bot_bottle/agent_provider.py—get_provider,PROVIDER_TEMPLATES,AgentProviderABCbot_bottle/manifest_agent.py— template validation that this PRD relaxes