Writing Plugins
For operation failures, raise arbiter_server.errors.OperationError with a
shared generic code and a plugin-owned domain code. The plugin chooses the
closest generic classification; Arbiter preserves it and maps it to HTTP.
Messages and details must be safe for callers. Unexpected exceptions become
internal_error. See the error contract for retry
guidance, partial outcomes, and the generic catalog.
An Arbiter plugin is a Python package that teaches Arbiter how to use one service area, such as a mail provider, database, ticket system, or internal API. The plugin owns the service-specific configuration, builds the runtime client for that service, and describes the operations that agents can discover and call through Arbiter.
For a copyable starting point, see
examples/plugins/echo.
It includes config schemas, bootstrap examples, runtime behavior, operation
metadata, entry-point wiring, and focused tests. Copy the structure, but rename
the package, import module, entry point, and capability before using it as a
real plugin.
What You Build
A useful plugin usually has these pieces:
- a Python distribution package
- an
arbiter.servicesentry point - account and policy config schemas
- bootstrap templates for new accounts and policies
- a runtime object that talks to the service
- service access enforcement according to the configured policy
- operation descriptions and dataclass input schemas
- operation invocation code
- optional config checks and account tests
The server handles shared concerns such as loading plugins, composing config, validating operation inputs, dispatching calls, and reporting plugin metadata. The plugin should stay focused on the service it owns.
1. Choose Names And Package Metadata
Use one short capability name for the service, such as smtp, imap, or
echo. That name becomes the plugin id shown to users and the prefix for
operation ids.
The example service uses echo as its capability name, so the entry-point
label in the package metadata is also echo:
Plugins are discovered through Python package entry points:
[project.entry-points."arbiter.services"]
echo = "arbiter_echo_example:plugin"
The entry point target must return a plugin object that implements
ServicePlugin. Using ServicePlugin in the class and factory signatures gives
static type checkers a clear contract. Arbiter still validates the object
structurally at runtime; it does not require inheritance.
Arbiter reads raw configuration and activation choices before composing the
full Hydra configuration. It imports only the plugins needed by selected
accounts, policies, schemas, or explicit bootstrap commands. The entry-point
key must match the plugin object's name and its configuration group name;
this lets Arbiter select the plugin without executing its code first.
Runtime construction loads only services with configured accounts. Installing
a plugin alone does not activate it. Commands that explicitly inspect all
plugins, such as plugins list --json and version, may import all of them.
Listing installed plugin names or activation status uses metadata without
importing them.
Activated or explicitly loaded plugins are fully trusted Python code in the server process. They can access process credentials and other plugins' data; plugin storage directories provide organization, not security isolation.
Plugins use compatibility-line versions. A plugin for Arbiter server 0.9.x
should use a package version on the 0.9 line, such as 0.9.0 or 0.9.1,
and the plugin object returned by the entry point must declare the same server
API line. In practice, the metadata and typed factory sit together:
from arbiter_server.services import ServicePlugin
from arbiter_server.version import distribution_version
class ExampleServicePlugin(ServicePlugin):
name = "example"
version = distribution_version("arbiter-example", package_file=__file__)
server_api_version = "0.9"
# Implement the ServicePlugin methods here.
def plugin() -> ServicePlugin:
return ExampleServicePlugin()
Declare the same compatibility line in package dependencies:
dependencies = [
"arbiter-server>=0.9.0,<0.10.0",
]
At plugin discovery and config registration time, Arbiter rejects plugins whose
server_api_version does not match the loaded server API line. It also rejects
plugin package versions that are not on that same major.minor line.
2. Define Account And Policy Config
Accounts identify configured service connections. Policies describe what those accounts are allowed to do. Keep them separate: bootstrapping commonly creates one policy per account, but operators can point multiple accounts at the same policy when that is useful.
Register plugin-owned schemas under the service-specific config groups:
def register_configs(config_store: ConfigStore) -> None:
config_store.store(
group="arbiter/account/echo",
name="schema",
node=EchoConfig,
provider="arbiter-echo-example",
)
config_store.store(
group="arbiter/policy/echo",
name="schema",
node=EchoPolicyConfig,
provider="arbiter-echo-example",
)
The server passes only service-owned config to the plugin:
arbiter.account.<service>arbiter.policy.<service>
The plugin should not need the full application config for ordinary operation.
Provide bootstrap templates for the config files operators create most often:
def bootstrap_config(self, *, kind: str, name: str) -> object | None:
if kind == "account":
return _account_template(name=name, policy_name=f"{name}_policy")
if kind == "policy":
return _policy_template(name=f"{name}_policy")
return None
A plugin may also define an activation_guidance string describing its
bootstrap defaults and what an operator should review. Explicit account
activation loads only its target plugins and prints this guidance as a warning
on stderr, including when the account is already active. This is setup
guidance, not a resolved-policy audit; activation does not resolve credentials
to render it. Omit the attribute when no guidance is needed.
Unavailable plugins or metadata errors produce a manual-policy-review warning
after the selection is recorded. Advisory guidance failures do not block
activation or suppress guidance for other targets; runtime compatibility checks
still apply when checking configuration or starting the server.
3. Build The Runtime
The runtime is the service implementation. It receives the already-composed accounts and policies for this plugin, validates relationships such as policy references, and owns the code that talks to the external service.
def build_runtime(
self,
accounts: Mapping[str, object],
policies: Mapping[str, object],
context: ServiceRuntimeContext,
) -> object:
return EchoRuntime(accounts=accounts, policies=policies)
Use ServiceRuntimeContext.dependencies for shared server-provided helpers
such as storage, artifact handling, client factories, or test doubles. Keep
credentials and service-specific rules in plugin config and policy, not in
operation arguments.
4. Describe The Agent Surface
The capability description tells users what the plugin does:
def describe_capability(self, context: ServicePluginContext) -> CapabilityDescriptor:
return CapabilityDescriptor(
name=self.name,
description="Echo messages through configured example accounts.",
)
Each operation has a short name, a description, and a dataclass input schema. The dataclass encodes field types and defaults; field metadata supplies client-facing descriptions and numeric bounds. Arbiter derives the schema shown to clients and validates/coerces caller input with OmegaConf before invocation:
Keep operation input schemas simple. Supported field types are str, int,
bool, list[...], and optional forms such as str | None. Use dataclass
defaults or default_factory for default values. The supported field metadata
keys are description, minimum, and maximum.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class EchoMessageInput:
account: str = field(
metadata={"description": "Configured echo account name."},
)
message: str = field(metadata={"description": "Message to echo back."})
uppercase: bool = field(
default=False,
metadata={"description": "Return the echoed message in uppercase."},
)
OperationDescriptor(
name="echo_message",
description="Return a policy-checked echo response for the selected account.",
input_schema=EchoMessageInput,
)
Arbiter forms operation ids from the capability and operation name:
<capability>:<operation>
For example:
smtp:send_email
imap:list_messages
Keep operation inputs about the requested action. Do not expose transport details, credentials, or policy toggles as operation parameters.
5. Invoke Operations
Before invocation, Arbiter validates and coerces arguments against the operation's dataclass input schema. The plugin then dispatches the operation to its runtime and enforces service-specific policy:
def invoke_operation(
self,
operation: str,
arguments: Mapping[str, object],
context: ServicePluginContext,
) -> object:
if operation != "echo_message":
raise ValueError(f"unknown echo operation: {operation}")
runtime = context.runtimes.require(self.name, EchoRuntime)
return runtime.echo_message(
account=cast(str, arguments["account"]),
message=cast(str, arguments["message"]),
)
Return small structured results with the information the caller needs. Keep service clients, connection pools, and temporary state inside the runtime.
Native HTTP invocation runs synchronous operations in a bounded worker pool. Admission happens before operation-body reads; body parsing and invocation share capacity with artifact storage and streaming. Slow clients can occupy this capacity, while health and discovery remain outside the pool. Several calls can use the same plugin and runtime concurrently. Protect shared mutable state and atomic policy admission, and use service clients that support concurrent calls or create a client per operation. Request cancellation does not interrupt an operation that is already running.
6. Add Operator Feedback
Config checks help operators catch mistakes before they start using the
deployment. Implement check_config when the plugin can validate references,
required settings, policy shape, or service-specific invariants from local
config alone.
Runtime account summaries and account tests improve discovery and deployment checks:
account_summaries()returns user-facing account details.test_accounts()verifies configured accounts. For some services this is a local validation; for others it may be a live connection check.
For native account and policy detail discovery, plugins can optionally implement
public_account_config(config) and public_policy_config(config). Each receives
the relevant configured object and returns a plain mapping of explicitly chosen
public fields. Without a hook, the server exposes an empty config or rules
object. Keep public metadata and all discovery hooks free of secrets.
Build these projections field by field, including nested objects. Never return the original config, serialize a complete dataclass or OmegaConf tree, or use a key-name blacklist to guess which fields are private. New schema fields must remain private until deliberately added to a projection. SMTP and IMAP publish sender/folder metadata and operation policy while withholding connection settings and credentials.
Use warnings for questionable but allowed config. Raise a config-check error for config that cannot work.
7. Test The Plugin
Use the echo plugin tests as the basic pattern. Cover:
- runtime policy enforcement
- config validation and unknown policy references
- operation discovery and input schema
- operation invocation through the catalog
- account summaries and account tests, if implemented
- bootstrap output for account and policy files
- error messages for common operator mistakes
Prefer focused unit tests for plugin logic. Add live service tests only where a real service boundary must be verified.
Author Checklist
Before publishing or installing a plugin, confirm that:
- the distribution package installs cleanly
- the
arbiter.servicesentry point returns a plugin object name,version, andserver_api_versionare correct- config schemas register under the plugin's account and policy groups
- bootstrap creates useful account and policy examples
- operations have clear descriptions and typed dataclass input schemas
- policy is enforced in runtime behavior, not only in docs
- config checks catch the mistakes operators are likely to make
- tests exercise discovery, invocation, policy, and failure paths