Skip to content

Adding a Provider

A provider integration lives in its own package. The shipped providers are under agentshim/providers/<name>/; an application or third party can keep the same layout in a package outside agentshim. Shared behaviour (tool pairing, JSON line handling, MCP file merge, schema materialization) lives in core/.

Put argv construction in provider.py, stream parsing in parser.py, and provider-specific options beside them. Add a plugin.py that exports one object implementing the core ProviderPlugin protocol. Every method is required. connect is the single connection entry point; connection_modes declares which modes it accepts. An unsupported mode raises ProviderCapabilityError. The built-in providers use a StaticProviderPlugin adapter for their fixed declarations, but third-party plugins may compute answers directly. No provider registers itself on import.

describe reports agent capabilities without checking the local install; probe checks local readiness using the supplied executor and environment. model_info returns a ModelSpec (including a context window and provenance) or None; pricing_info returns a ModelPricing for a PricingContext or None. These methods can calculate answers dynamically. known_models and known_prices enumerate facts for versioned table snapshots and may return empty tuples even if dynamic lookups are available. test_support returns consumer-test adapters or None. Methods are never omitted to signal lack of a capability. A caller may still pass a ModelTable or PricingTable to override or pin lookup facts. Identical snapshot entries from multiple plugins deduplicate; conflicting entries raise an error.

Import core/ and execution/ absolutely (from agentshim.core.events import ...) and your own siblings relatively (from .parser import ...).

The protocol

class ProviderPlugin(Protocol):
    @property
    def name(self) -> str: ...
    def describe(self) -> ProviderProfile: ...
    def connection_modes(self) -> frozenset[TransportKind]: ...
    def connect(self, options: ConnectionOptions) -> Provider | Transport: ...
    def probe(self, executor: CommandExecutor, env: Mapping[str, str], timeout: float) -> ProviderStatus: ...
    def model_info(self, model: str, table: ModelTable | None = None) -> ModelSpec | None: ...
    def pricing_info(self, model: str, context: PricingContext, table: PricingTable | None = None) -> ModelPricing | None: ...
    def known_models(self) -> tuple[ModelSpec, ...]: ...
    def known_prices(self) -> tuple[ModelPricing, ...]: ...
    def test_support(self) -> ProviderTestSupport | None: ...

The one-shot Provider below is a separate, lower-level protocol returned by connect(ConnectionOptions(TransportKind.ONE_SHOT)). A stream connection returns Transport. The union preserves both existing runtimes behind one plugin method; the requested kind determines which is returned.

class Provider(Protocol):
    profile: ProviderProfile
    def build_argv(self, ctx: ArgvContext) -> list[str]: ...
    def new_parser(self, emit: Callable[[AgentEvent], None], *, expect_structured: bool) -> StreamParser: ...
    def install_mcp(self, workspace: Path | None, servers: Sequence[McpServer]) -> McpInstallation: ...
    def classify_exit(self, error: CliExitError, *, resumed: bool) -> AgentShimError: ...

ArgvContext carries the binary path, model, env, resume id, reasoning effort, the inline schema or the CLI-visible schema path, the MCP flags, and the caller's extra args. It deliberately does not carry the prompt: the prompt always goes on stdin, so an agent's own pkill -f cannot match the CLI by prompt text.

install_mcp returns an McpInstallation whose restore() -> str | None must be idempotent and must never raise: it runs from the session's finally, where an exception would destroy the turn's result or mask the error it failed with. Return a note describing anything cleanup could not do normally, and None otherwise.

ProviderProfile declares every optional behaviour. Fill in all of it, including state_dirs, auth_env_vars, skill_dirs and container_install: callers read these instead of hardcoding provider names.

The parser

class StreamParser(Protocol):
    def feed_stdout(self, line: str) -> None: ...
    def feed_stderr(self, line: str) -> None: ...
    def finish(self) -> ParsedTurn: ...

Use parse_json_object(line), which returns None for blank lines, invalid JSON, and valid JSON that is not an object; emit RawOutput for those rather than raising. Use ToolTracker to pair result frames back to their calls. Normalize usage with normalized_usage into the breakdown in Usage and Pricing. Report a failed tool on ToolResult.stderr with a nonzero exit_code, never on stdout.

Optional capabilities

A plugin can offer more than the required methods. Each is a separate runtime_checkable protocol that agentshim looks for with isinstance, so a plugin without it keeps working and gets the documented default.

Protocol Method Used by Without it
RateCatalog known_rates() -> RateTable bundled_rates(), the pinned table of estimates The provider ships no rates
RateNaming rate_names(model) -> tuple[str, ...] rates_for The generic rules of candidate_names
TranscriptSource transcripts() -> TranscriptFormat \| None transcript_roots, read_transcript_usage The provider's transcripts are not read

StaticProviderPlugin implements all three from fields: rates, unpriceable_models and transcript_format. See Usage and Pricing and Usage Outside agentshim.

Test doubles

Expose scripted_lines(...) in scripted.py, returning the provider's real stdout for one turn. Put it, resume_failure_lines, and any probe or MCP test adapters in the ProviderTestSupport returned by test_support(). Consumer test helpers receive the same catalog as the runtime API and round-trip the stream through the provider's own parser. If the provider has no native output schema, raise ValueError when structured_output is passed rather than ignoring it.

Tests

Put them in tests/unit/providers/<name>/ as test_argv.py, test_parser.py, test_provider.py and test_scripted.py, grouping cases in Test* classes. tests/unit/providers/test_conventions.py is parametrized over provider_names() and will pick the new provider up automatically: it pins the profile fields, the prompt staying out of argv, the resume flag, the scripted round trip, the usage invariant, and RawOutput for non-object JSON. Add the new name to its RESUME_FLAGS map. An external plugin can run the equivalent tests in its own repository; tests/external_provider/ is an example that works without adding a module to the agentshim namespace.

Installing it

from agentshim import Agent, ApprovalPolicy, CliAgent, DEFAULT_CATALOG, NativePermissions
from myagent.plugin import PLUGIN

catalog = DEFAULT_CATALOG.with_plugin(PLUGIN)
cli = CliAgent("myagent", catalog=catalog, model="my-model-id")
agent = Agent(
    "myagent", catalog=catalog,
    permissions=NativePermissions.bypass(), approvals=ApprovalPolicy.DENY,
)

At least one transport factory is required, so a stream-only integration does not need to implement the one-shot Provider protocol. ProviderCatalog.with_plugin returns a new catalog and rejects duplicate names. Pass it to Agent, CliAgent, probe_provider, provider_names, or the consumer test helpers. The built-in default remains unchanged. A caller can still pass a Provider instance to CliAgent or a Transport instance to Agent without using a catalog. A plugin may live in a separately installed package or a local application; publishing to agentshim is not required.