Plugins and extensions
Choose the narrowest extension point: middleware, Provider plugins, extras, or Environment Run extensions.
Agent Harness exposes focused extension points rather than one universal plugin interface. Choose the narrowest boundary that owns the behavior and lifetime you need.
| Extension point | Use it for | Lifecycle | Model-visible |
|---|---|---|---|
| Harness middleware plugin | Transform semantic input, observe events, wrap errors, or replace a complete result candidate | Agent-bound at build, then freshly run-bound around each logical run | Only through an explicit Capability contribution |
| Pydantic Capability | Own or compose Toolsets, instructions, request hooks, Agent-loop state, and collaboration with other run Capabilities | Native Pydantic Agent/run lifecycle | Yes |
EnvironmentProviderBinding | Implement one already selected provider-neutral Environment operation revision | One binding scope inside one EnvironmentRuntime | Only through explicit Environment tools/context |
EnvironmentRunExtension | Hold a resource that needs the complete entered Environment aggregate; use EnvironmentRunCallbacks for simple paired callbacks | Entered with the current aggregate; reverse-order exit before provider teardown | No |
| Provider plugin | Add an Environment Provider your Host can select | Inert definitions loaded once at Host startup | No |
Installed entry-point metadata means code is available, not enabled or authorized. Importing a13n_harness scans no entry points and activates no extension.
Choose and Activate an Extension Point
Availability, selection, and activation are separate decisions:
| Extension point | Makes an implementation available | Selects and activates it | Automatic behavior |
|---|---|---|---|
| Harness middleware plugin | Install a package with an a13n_harness.plugins entry point, or import a concrete plugin | Enable one configured plugin_key/plugin_id, or pass the concrete plugin to HarnessBuilder.build() | Ambient configuration is disabled by default; after explicit opt-in, only entries with enabled: true are loaded |
| Pydantic Capability | Import a concrete Capability, or let a trusted Host authorize an exact declarative type | Put the instance in definition/run composition, or put its serialized spec in AgentSpec.capabilities | Optional Capabilities are never inferred from package presence; see Capabilities |
EnvironmentRunExtension | Install a package with an a13n_harness.environment_run_extensions entry point, or import a concrete extension | Select an exact factory key, create one identified instance, and pass it to create_environment_runtime(extensions=...) | There is no ambient configuration or automatic selection |
EnvironmentProviderBinding | Construct a fresh trusted binding from the owning Provider layer | Put the binding in an EnvironmentRuntimeMount | Provider availability never mounts or exposes model tools by itself |
| Provider plugin | Install a distribution with an a13n_harness.providers.plugins entry point | Name that entry point in the Host's enabled-plugin selection, then select a Provider type by name | Installation activates nothing; an unselected entry point is never imported |
AgentSpec selects only Capabilities. It does not select Harness middleware, Environment run extensions, Providers, credentials, or live collaborators. A selected plugin may contribute ordinary Capabilities from trusted plugin code, but that contribution is owned by the plugin rather than reconstructed from AgentSpec.
Harness Middleware
Harness plugins are trusted Python middleware around one complete process-local run. Use a Pydantic AI Capability for behavior inside the Agent loop. Use middleware when behavior must wrap semantic input, the canonical event stream, errors, or the complete result boundary.
A plugin can be supplied directly as an AbstractHarnessPlugin or created from a selected HarnessPluginFactory entry point.
Direct Composition
Direct objects are the simplest choice for an embedded application:
from a13n_harness import HarnessBuilder
plugin = AuditPlugin("audit-primary")
executable = HarnessBuilder().build(
agent_spec,
output_type=str,
model=model,
plugins=(plugin,),
)Direct and configured plugins enter the same ordering, Agent binding, run binding, middleware, result validation, and cleanup path.
Feature Capabilities and Current Providers
Middleware can coexist with a first-party feature without owning another feature attachment. The Host selects one configured WebCapability in the Agent definition and supplies current clients and policy separately. Reserved first-party Capability source rules still apply: WebCapability belongs to the definition, not a plugin's get_capabilities() contribution.
from a13n_harness import RunBindings
from a13n_harness.capabilities import WebBinding
result = await executable.run(
"Read the page",
bindings=RunBindings.embedded(
web=WebBinding(client=web_client, policy=web_policy),
),
)Do not add a companion provider Capability to the plugin or put provider objects in plugin YAML. RunBindings.capabilities remains the source for invocation-policy and MCP Capabilities, not passive feature dependencies. A binding cannot enable a missing feature owner, and provider clients never enter HarnessState. The Host owns their lifetime; a plugin's for_run() remains its ordinary middleware-isolation hook, not another feature API.
The offline middleware and Web example exercises a recorder plugin alongside a definition-owned Web feature, fresh typed bindings, and the standard fetch tool without a network request.
Publish a Plugin Factory
Register one no-argument factory class:
[project.entry-points."a13n_harness.plugins"]
"acme.audit" = "acme_harness.plugin:AuditPluginFactory"The entry-point name and plugin_key() must match:
from a13n_harness import AbstractHarnessPlugin
from a13n_harness.plugin_factories import (
HarnessPluginFactory,
HarnessPluginFactoryContext,
)
class AuditPlugin(AbstractHarnessPlugin):
def __init__(self, plugin_id: str) -> None:
self._plugin_id = plugin_id
@property
def plugin_id(self) -> str:
return self._plugin_id
class AuditPluginFactory(HarnessPluginFactory):
@classmethod
def plugin_key(cls) -> str:
return "acme.audit"
def create_plugin(
self,
context: HarnessPluginFactoryContext,
) -> AbstractHarnessPlugin:
# Validate context.configuration with a package-owned schema.
return AuditPlugin(context.plugin_id)Factory construction is synchronous and side-effect free. create_plugin() returns a fresh concrete plugin for each configured instance. Mutable run data belongs in the exact run-bound plugin returned by for_run().
Select Configured Plugins
The Harness plugin document is a data schema, not a required file format:
from a13n_harness import HarnessBuilder
from a13n_harness.plugin_configuration import HarnessBuildContext
configuration = {
"schema_version": "1",
"plugins": [
{
"plugin_id": "audit-primary",
"plugin_key": "acme.audit",
"enabled": True,
"configuration": {"mode": "metadata"},
}
],
}
context = HarnessBuildContext.from_configuration(configuration)
builder = HarnessBuilder(build_context=context)The same schema can come from YAML or JSON:
schema_version: "1"
plugins:
- plugin_id: audit-primary
plugin_key: acme.audit
enabled: true
configuration:
mode: metadatacontext = HarnessBuildContext.from_file("harness-plugins.yaml")
builder = HarnessBuilder(build_context=context)Configuration selects stable entry-point keys and never accepts a module:object target. HarnessBuildContext.extensions is a bounded namespaced JSON value forwarded to selected plugin factories; despite its name, it is not a plugin or Environment-extension list and does not enable anything.
Optional Environment Source
Configured plugins are disabled by default. A deployment can opt into an environment-selected document:
export A13N_HARNESS_PLUGIN_CONFIG_ENABLED=true
export A13N_HARNESS_PLUGIN_CONFIG_FILE=/etc/a13n/harness-plugins.yamlbuilder = HarnessBuilder()When disabled, builder construction does not read the file or scan package metadata. When enabled, it imports only factory keys selected by enabled entries.
Runtime Plugin Directories
A long-lived Host can publish a complete installed distribution in a new immutable directory and add that directory to sys.path before constructing a replacement builder:
import importlib
import sys
from pathlib import Path
def activate_plugin_directory(path: str | Path) -> Path:
plugin_directory = Path(path).resolve()
normalized = str(plugin_directory)
if normalized not in sys.path:
sys.path.append(normalized)
importlib.invalidate_caches()
return plugin_directoryThe directory must contain both the import package and standard distribution metadata with the entry point. A loose .py file is not sufficient.
Publish into a fresh directory that is not yet searchable, validate the complete installation, atomically place it, activate that exact path, then build a replacement executable. Do not mutate an already imported release in place or append two releases that own the same plugin key.
A replacement builder with configured plugins explicitly enabled resolves current distribution metadata only for factory keys selected by its enabled entries. A disabled builder still performs no metadata scan. An existing builder retains its selected factory catalog; an existing executable retains its already constructed plugin graph. Keep old executables alive until their active runs finish.
The Host remains responsible for artifact trust, dependency compatibility, installation locks, directory ordering, and rollback. Harness includes no package installer or process-global mutable plugin registry.
Plugin Lifecycle
A plugin has three distinct phases:
- factory selection and creation during builder construction for configured plugins;
- Agent binding once for each built root or child executable;
- run binding and middleware freshly for every logical run.
Run middleware must preserve single-consumer streaming and yield exactly one structurally valid result candidate. Use try/finally for plugin-owned cleanup. Do not swallow cancellation or convert cleanup failure into clean completion.
Plugins can contribute native Capabilities at Agent binding. Harness calls get_capabilities() on the instance returned by for_agent(); do not extract contributions before that binding. They should not implement a second tool dispatcher, message history, usage accumulator, or Environment lifecycle.
To support optional grouped presentation, a plugin can expose source factories or a presentation option for its contribution. A Host-owned composition layer can aggregate selected sources into one ToolProxyCapability(groups=...), while retaining required middleware and leaving unrelated tools direct. This requires an explicit plugin integration interface, not generic lookup or interception of arbitrary plugins. See ToolProxy plugin-contributed sources for an example and duplicate-installation boundaries.
Provider Plugins
A Provider plugin adds one or more Environment Providers your Host can select, through one entry-point group and one immutable manifest. Model, Web, and Connector definitions use the same definition contract, but a Host composes them in code and selects them through its own ProviderCatalog.
A definition is a frozen value. It declares its stable type, a display_name, the typed configuration and credential models its inputs use, how credentials are required, and optional setup help. Importing it performs no I/O and creates no client:
from a13n_harness.providers.authentication import Authentication, CredentialMode
from a13n_harness.providers.environment.definition import EnvironmentProviderDefinition
ACME_SANDBOX = EnvironmentProviderDefinition(
type="acme_sandbox",
display_name="Acme Sandbox",
configuration_model=AcmeConnectionConfiguration,
credential_model=AcmeCredential,
environment_model=AcmeEnvironmentConfiguration,
construct=_construct,
describe_environment=_describe,
runtime_factory=_runtime,
authentication=Authentication(mode=CredentialMode.required),
setup_url="https://acme.example/dashboard",
setup_label="Acme dashboard",
supports_stop=True,
supports_destroy=True,
)One distribution exports one ProviderManifest per entry point:
from a13n_harness.providers.plugins import ProviderManifest
manifest = ProviderManifest(api_version=1, environment=(ACME_SANDBOX,))[project.entry-points."a13n_harness.providers.plugins"]
acme = "acme_providers:manifest"A Host names the entry points it trusts and builds one Environment catalog from its built-in and selected definitions:
from a13n_harness.providers.catalog import ProviderCatalog
from a13n_harness.providers.plugins import load_provider_plugins
plugins = load_provider_plugins(("acme",))
environments = ProviderCatalog(
item for plugin in plugins for item in plugin.manifest.environment
)
definition = environments.require("acme_sandbox")Selection is explicit at every step. Installing the distribution activates nothing; an entry point you do not name is never imported; and a catalog rejects a type that duplicates another definition in the same domain. require() raises ProviderNotSelected for a type this deployment does not offer, so a Host can report a safe configuration error instead of failing unexpectedly.
The runnable plugin example publishes one manifest and a separate Harness middleware plugin from the same project. The installed Provider plugin example shows direct use and Harness UI loading.
Harness Extras
The base a13n-harness installation contains every Provider definition, so metadata, schemas, and Console forms work without an optional dependency. Vendor SDKs are separate extras:
| Extra | Adds | Needed by |
|---|---|---|
docker | The Docker SDK for Python | The docker Environment Provider |
e2b | The asynchronous E2B SDK | The e2b Environment Provider |
modal | The Modal SDK | The modal Environment Provider |
uv add "a13n-harness[docker,e2b]"Importing Harness or reading a Provider's metadata never imports these SDKs. A Provider whose extra is missing fails with a bounded configuration error when it is actually opened, not at import.
Environment Inputs and Advanced Bindings
When a Provider has already constructed an Environment, pass it directly to run(environment=...) or wrap it in EnvironmentMount to select a permission ceiling and paths. Explicit runtimes and their dynamic mount() and replace() methods accept the same inputs. Harness owns entry and local cleanup; Host code does not need to implement a forwarding binding class:
from a13n_harness.environment import (
FILE_ACTIONS,
EnvironmentMount,
EnvironmentPermissionSet,
)
from a13n_harness.environment.advanced import create_environment_runtime
environment_runtime = create_environment_runtime(
mounts={
"workspace": EnvironmentMount(
environment=environment,
permission_ceiling=EnvironmentPermissionSet(operations=FILE_ACTIONS),
),
},
default_mount="workspace",
)permission_ceiling accepts any exact action set, which is useful for a setup extension that needs only selected file operations. Provider permissions always narrow the ceiling. Each underlying Environment transfers only once, even if it is wrapped in another EnvironmentMount; invalid initial routes do not transfer it, and a failed attempt to reuse it cannot close its existing scope.
Advanced Provider Binding Scopes
Use EnvironmentProviderBinding with EnvironmentRuntimeMount when a Host needs to acquire an authenticated session or another resource inside a custom async bind() scope and expose provider-neutral file, shell, process, output, port, readiness, and portable-state operations. This advanced input remains accepted by explicit runtime construction and dynamic mount replacement. An existing Environment should use the direct inputs above.
That is a low-level runtime binding contract. Provider catalogs, Environment Provider lifecycle operations, credential handling, and durable provider state belong to Provider plugins and the Host, not to Harness middleware.
An EnvironmentProviderBinding is fresh and single-use. Effectful allocation, authentication, session entry, maintenance tasks, and cleanup-producing work belong inside its async bind() scope or in the owning provider layer, never in import-time discovery or an inert factory constructor.
Environment Run Extensions
Use an EnvironmentRunExtension when setup and teardown need the stable complete EnvironmentRuntime, including an empty, single-mount, or multi-mount runtime.
Callback Composition
For ordinary Host setup and cleanup, register an EnvironmentRunCallbacks adapter instead of defining an extension class:
from a13n_harness.environment import (
EnvironmentRunCallbacks,
EnvironmentRunExtensionContext,
)
async def prepare_environment(
context: EnvironmentRunExtensionContext,
) -> None:
await context.environment.files.write_text(
"/workspace/.active-run",
f"{context.run_id}\n",
mode="create",
)
async def clean_environment(
context: EnvironmentRunExtensionContext,
) -> None:
await context.environment.files.remove(
"/workspace/.active-run",
)
active_run_callbacks = EnvironmentRunCallbacks(
extension_id="workspace-marker",
on_enter=prepare_environment,
on_exit=clean_environment,
)on_enter runs after portable Environment state restoration and before runtime activation. It participates in making the runtime active; it does not mean every operation family is globally ready. Call context.environment.ensure_ready() when setup depends on an exact family. on_exit runs during reverse-order Environment teardown while provider-neutral operations remain available. It also runs after failure, cancellation, or rollback following successful entry, so it is cleanup rather than a success notification.
Each adapter is one identified extension. Multiple adapters enter in registration order and exit in reverse order. Callback failures use the same authoritative failure semantics as custom extension setup and cleanup. Catch expected failures inside a callback only when the Host deliberately wants best-effort behavior.
Custom Resource Scope
Use a custom async context manager when setup and cleanup share local state or need a richer resource scope:
from contextlib import asynccontextmanager
from a13n_harness.environment import EnvironmentRunExtensionContext
class WorkspaceMarkerExtension:
def __init__(self, extension_id: str) -> None:
self._extension_id = extension_id
@property
def extension_id(self) -> str:
return self._extension_id
@asynccontextmanager
async def bind(self, *, context: EnvironmentRunExtensionContext):
path = "/workspace/.active-run"
await context.environment.files.write_text(
path,
f"{context.run_id}\n",
mode="create",
)
try:
yield
finally:
await context.environment.files.remove(path)Register direct extension objects on an explicit runtime with ordinary Environment inputs:
from a13n_harness.environment.advanced import create_environment_runtime
environment_runtime = create_environment_runtime(
mounts={"workspace": environment},
default_mount="workspace",
extensions=(WorkspaceMarkerExtension("workspace-marker"),),
)Give the runtime to the Harness through fresh run bindings. Harness binds, activates, and closes it:
from a13n_harness import RunBindings
bindings = RunBindings.embedded(environment=environment_runtime)
result = await executable.run("Use the prepared workspace", bindings=bindings)The same extensions= sequence accepts callback adapters and custom extension objects together:
environment_runtime = create_environment_runtime(
mounts=mounts,
default_mount="workspace",
extensions=(
active_run_callbacks,
WorkspaceMarkerExtension("custom-marker"),
),
)Extensions enter after providers are available and portable Environment state is restored. They exit in reverse order while the Environment is still open and before provider scopes close.
Explicit Extension Factories
A distribution can register a side-effect-free factory under:
[project.entry-points."a13n_harness.environment_run_extensions"]
"acme.workspace-marker" = "acme_environment.extension:WorkspaceMarkerFactory"The Host explicitly selects keys with build_environment_run_extension_factory_catalog() or provides exact factories. Harness owns no ambient configuration document for Environment run extensions. A complete installed-factory path is:
from a13n_harness import RunBindings
from a13n_harness.environment import (
EnvironmentRunExtensionFactoryContext,
build_environment_run_extension_factory_catalog,
)
from a13n_harness.environment.advanced import create_environment_runtime
catalog = build_environment_run_extension_factory_catalog(
extension_keys=("acme.workspace-marker",),
)
extension = catalog.create_extension(
EnvironmentRunExtensionFactoryContext(
extension_key="acme.workspace-marker",
extension_id="workspace-marker-primary",
configuration={"marker_path": "/workspace/.active-run"},
)
)
environment_runtime = create_environment_runtime(
mounts=mounts,
default_mount="workspace",
extensions=(extension,),
)
bindings = RunBindings.embedded(environment=environment_runtime)
result = await executable.run("Use the prepared workspace", bindings=bindings)extension_key chooses one installed factory; extension_id identifies one concrete aggregate instance and must be unique within that runtime. Catalog construction imports only explicitly selected keys. Passing a concrete extension directly skips metadata discovery entirely.
Runnable Example
The integration package example contains Host-authorized custom Capability selection, wheel-ready middleware and Environment extension entry points, direct-code composition, YAML selection, public Harness execution, per-run isolation, and offline tests.