#Extension seams
On this page
Case study: adding OpenCodeWhat already works as a seamWhere transport knowledge leaksMatches on HostTransport outside jam-hostThe same wire names in five placesProvider string literalsDesktop copyChecklist: adding a harness todayChecklist: adding a cross-cutting featureObservations for the refactorAdding OpenCode, a coding-agent harness that reuses the existing ACP protocol adapter, changed 25 files across seven Rust packages, the desktop TypeScript, and documentation. That is one measured extension, not a minimum for every harness. The seam that is supposed to hold provider knowledge, jam-host, does hold the protocol code, but the facts about each transport (its name, its settings, its credentials, its sandbox rules, its provider tag) are spread across the domain, manager, store, CLI, analytics, and desktop. The measurements are from main and identify what a plugin boundary would have to own.
The analysis covers two kinds of extension: a new harness (a coding-agent runtime Jam can own or attach to), and a new cross-cutting feature (a capability every harness should expose, such as a new runtime tool).
#Case study: adding OpenCode
Commit 3520e617f ("feat(opencode): first-party OpenCode runtime for hosted agents", 2026-09-18) added OpenCode as a first-class transport. OpenCode speaks Agent Client Protocol, so the commit reused the existing ACP adapter rather than writing a new protocol client. It still changed 25 files with 1,987 inserted lines:
| Area | Files | What had to change |
|---|---|---|
| Domain | crates/jam-domain/src/host_session.rs (215 lines added, 30 removed), lib.rs |
New HostTransport::Opencode variant, serde name, catalog row, is_owned_opencode, sandbox rules |
| Provider adapter | crates/jam-host/src/opencode.rs (new, 432 lines), acp.rs (814 added, 16 removed), lib.rs |
OpenCode-specific launch and state, plus ACP changes to accommodate it |
| Composition | bins/jam/src/jamd.rs (47 added, 3 removed) |
One more condition in the build_host if-chain, and an error message listing transports |
| Manager | crates/jam-manager/src/manager.rs (264 added, 8 removed), runtime_host.rs, worker.rs, analytics.rs (+19) |
apply_runtime_opts, parse_runtime_transport, runtime_transport_name, default_owned_runtime_provider, clear_unsupported_thread_settings_for_transport, model checks, a transport tag, and an analytics enum mapping |
| Store | crates/jam-store/src/sqlite.rs |
Two more entries in the transport string maps |
| Contract and analytics | crates/jam-contract/src/lib.rs, crates/jam-analytics/src/contract.rs |
Doc strings listing transports; a new analytics enum value |
| CLI | bins/jam/src/main.rs, agent_management.rs, cli-help.golden.txt |
A new RuntimeTransport clap value, its wire mapping, help text |
| Desktop | LocalAgentCreatePanel.tsx, RuntimeTemplatePanel.tsx, fixtures.ts (+81), bindings.ts |
A title and description in providerCardCopy, a label in transportLabel, fixture rows, regenerated bindings |
| Docs | CHANGELOG.md, docs/analytics.md, docs/user-guide.md |
Release notes and analytics documentation |
The adapter code (about 1,260 lines in jam-host) is the part that is inherently per-harness. Most of the rest registers the same facts in parallel places.
#What already works as a seam
Several abstractions already do the job a plugin boundary needs, and the refactor should build on them rather than replace them.
The Host trait (crates/jam-host/src/lib.rs). Every adapter implements one trait: name, delivery_mode, prepare, teardown, teardown_for, and deliver through the jam-core::Deliverer supertrait. Optional capabilities have default bodies: compact, interrupt_turn, runtime_models, notify_room_unbound, runtime_pid, runtime_transport_active, idle_reap_policy, and readiness_warning. The manager drives every adapter through this trait and never names a concrete adapter type.
RuntimeHostContext (crates/jam-host/src/lib.rs). The manager hands each owned adapter a context carrying every provider-neutral broker it may need: event sink and ingress, permissions, environment approvals, questions, messages, roster, room and plan context, runtime tools, task access, artifacts gate, checkpoint broker and coordinator, resume observer, and team context. Adapters translate native requests into these brokers. That keeps questions, permissions, usage, and activity provider-neutral downstream.
Lease-scoped event ingress. Every event an owned adapter emits carries the RuntimeEventLease of the host instance that produced it, and passes through a RuntimeEventIngress that buffers startup events, releases them only after the worker commits ownership, and rejects events from suspended or retired hosts (state machine). A plugin adapter must emit through this ingress, not straight to the manager. Implementing Host alone is not enough.
The transport catalog (transport_catalog! macro and HostTransport::catalog() in crates/jam-domain/src/host_session.rs). One macro row per transport declares its label, default command, provider tag, environment allowlist, the offered and resolvable credentials, Docker sandbox capability cells, the sandbox GitHub credential policy, control channel, model support, create-picker visibility, integration provider, permission mode, launch examples, and runtime settings. HostTransport::ALL is generated from the rows, so a variant without a row fails to compile. The desktop reads the catalog through the runtime_transport_catalog command and derives its create picker and settings forms from it (apps/desktop/src/lib/runtimeTransports.ts), and apps/desktop/src-tauri/tests/transport_catalog_fixture.rs checks the desktop test fixture against it. This is the nearest thing Jam has to a plugin manifest.
Provider-neutral event and usage types. Adapters emit AgentEventKind values, including Usage with optional token categories, and all downstream layers use ActivityReport and ActivityKind. Usage from any owned adapter enters one pipeline (worker::runtime_usage_observation).
The runtime tool registry. Tools exposed to owned agents (tasks, workspace operations, collaboration, reply disposition) are defined once in jam-host (runtime_tools.rs, runtime_task_tools.rs, runtime_workspace_tools.rs, runtime_collaboration_tools.rs, disposition.rs) and translated per provider. Codex receives them as app-server dynamicTools, and host-native Codex also receives task tools through a jam mcp tasks MCP server.
#Where transport knowledge leaks
Production code only: scripts/architecture/count-production-refs.mjs excludes #[cfg(test)] items, test files, and test kits.
Production HostTransport:: references by file, outside jam-host. The provider crate is the intended seam, but manager.rs alone holds 95 of the 188 references.
#Matches on HostTransport outside jam-host
| File | Production references | Functions |
|---|---|---|
crates/jam-manager/src/manager.rs |
95 | apply_runtime_opts (24), test_runtime_inner (9), clear_unsupported_thread_settings_for_transport (8), runtime_transport_name (8), parse_runtime_transport (7), set_runtime_settings (6), validate_owned_runtime_configuration (6), default_owned_runtime_provider (6), sandbox_agent_for_session (5), probe_runtime_models (4), probe_runtime_template_models (4), upsert_host_session (3), and others |
crates/jam-domain/src/host_session.rs |
38 | Catalog-derived rules such as resolved_sandbox_capability_cell and validate_sandbox_auth, Display, and is_owned_* helpers |
crates/jam-store/src/sqlite.rs |
21 | transport_str, the TRANSPORTS parse table, parse_transport |
bins/jam/src/main.rs |
10 | runtime_summary |
crates/jam-manager/src/analytics.rs |
9 | agent_runtime_configuration_for |
crates/jam-manager/src/runtime_host.rs |
8 | transport_tag |
crates/jam-domain/src/agent_event.rs (3); crates/jam-manager/src/managed_sandbox_kit.rs, crates/jam-manager/src/worker.rs, bins/jam/src/agent_management.rs, apps/desktop/src-tauri/src/commands.rs (1 each) |
1 to 3 each | Various |
Within jam-host itself, adapters reference HostTransport only a handful of times. The provider branching is concentrated in the manager, not in the provider seam.
#The same wire names in five places
The string form of each transport ("codex-app-server", "copilot-sdk", and so on) is written out by hand in five separate functions:
| Location | Purpose |
|---|---|
impl Deserialize for HostTransport in crates/jam-domain/src/host_session.rs |
Wire decoding, with an explicit refusal of the confusable claude-code |
transport_str and TRANSPORTS in crates/jam-store/src/sqlite.rs |
Database encoding, plus legacy aliases (app-server, codex, copilot) |
parse_runtime_transport and runtime_transport_name in crates/jam-manager/src/manager.rs |
CLI and request parsing, plus aliases (attach, inbox) |
transport_tag in crates/jam-manager/src/runtime_host.rs |
Compatibility fingerprint input |
runtime_summary in bins/jam/src/main.rs |
CLI display |
The enum-to-string functions (transport_str, runtime_transport_name, transport_tag, runtime_summary) are exhaustive matches, so adding a variant produces a compile error there. The string-to-enum direction is not: serde decoding falls back to Unknown, the store decodes through the hand-maintained TRANSPORTS table, and parse_runtime_transport has a catch-all error arm. A new variant compiles without entries in any of the three and fails only when a string is decoded. The facts live in five places with slightly different alias sets. AGENTS.md ("One representation, or make disagreement impossible") names this pattern as the root cause of recurring defects on the owned-runtime surface.
#Provider string literals
Production code outside jam-host also branches on provider name strings ("codex", "claudecode", "claude", "copilot", "opencode", "cursor"). The largest counts are in bins/jam/src/main.rs (20), crates/jam-manager/src/manager.rs (20), crates/jam-domain/src/host_session.rs (16), apps/desktop/src-tauri/src/integration_setup.rs (12), and bins/jam/src/jamd.rs (7). Inside jam-host, codex/app_server.rs (17) and copilot/sdk.rs (11) are expected owners of their own names.
Two composition points in bins/jam/src/jamd.rs choose behavior by provider name rather than by a registered capability:
build_hostis an orderedifchain overis_owned_*predicates, thensession.provider == COPILOT_PROVIDER, thenpeer.host == "claudecode", then aGenericfallback. The order matters. Owned Claude Code must be tested before the attached-Claude fallback, or an owned session silently gets mailbox delivery.new_work_sourceandwork_providerpick a private-task watcher by matching"claudecode"and"codex". Its comment says "a new provider is one more arm here + its host module."
#Desktop copy
The desktop derives capabilities and settings from the catalog, but two functions still restate per-transport copy: providerCardCopy in apps/desktop/src/components/blocks/LocalAgentCreatePanel.tsx (title and description) and transportLabel in apps/desktop/src/components/blocks/RuntimeTemplatePanel.tsx (a label that differs from the catalog's label). Both switch over the generated HostTransport type, so TypeScript rejects a missing case. PROVIDER_BY_TRANSPORT in ProviderIcon.tsx is a Record<RuntimeTransportView, ...> with the same property.
#Checklist: adding a harness today
A developer adding a new owned transport on main must touch at least the following. Compile errors from exhaustive matches catch most of them. The ones marked silent compile without the change and fail only at runtime or in review.
crates/jam-domain/src/host_session.rs: add theHostTransportvariant, its serde name inDeserialize, atransport_catalog!row, and anis_owned_<name>predicate.crates/jam-host/src/: write the adapter implementingHost, and translate native events, questions, permissions, usage, and tools to the manager's brokers.bins/jam/src/jamd.rs: add a branch tobuild_hostin the right position. Silent if omitted or misordered: the session falls through to the attached orGenericadapter.bins/jam/src/jamd.rs: extendwork_providerif the harness has a native task list. Silent if omitted: the lane stays CLI-only.crates/jam-manager/src/manager.rs: extendparse_runtime_transport,runtime_transport_name,default_owned_runtime_provider,apply_runtime_opts,clear_unsupported_thread_settings_for_transport,validate_owned_runtime_configuration, and the model probes as the harness requires.crates/jam-manager/src/runtime_host.rs: extendtransport_tag.crates/jam-store/src/sqlite.rs: extendtransport_strandTRANSPORTS. Silent for the parse table: a missing entry decodes asUnknown. The same is true of the serdeDeserializematch in step 1 andparse_runtime_transportin step 5.crates/jam-manager/src/analytics.rsandcrates/jam-analytics/src/contract.rs: add the analytics mapping and enum value, which requires a privacy review underdocs/analytics.md.bins/jam/src/main.rsandagent_management.rs: add the CLIRuntimeTransportvalue and its display, then runjust cli-help-update.- Desktop: add copy in
providerCardCopyandtransportLabel, an icon mapping, fixture rows, and runjust bindings. - If the harness supports Docker Sandbox: the catalog's sandbox cells, the managed sandbox kit (
crates/jam-manager/src/managed_sandbox_kit.rs), credential services, andsandbox_agent_for_session. - Tests: lifecycle tests, the catalog fixture test, and a packaged E2E scenario in the shared catalog (
apps/desktop/e2e/real-agents/scenario-catalog.mjs).
#Checklist: adding a cross-cutting feature
Control API methods by area, split by which client calls them. A new daemon-backed feature usually adds rows here, and each row is a full vertical change through jam-contract, jam-wire, jam-daemon, jam-client, jam-ipc, and the Tauri command allowlists.
A capability that every harness should expose, such as a new runtime tool or a new Control operation, has a different shape:
- A new runtime tool is defined once in the
jam-hosttool registry and reaches every owned harness throughRuntimeHostContext::tools. Each adapter must still translate it into the provider's tool mechanism, andAGENTS.mdrequires the tool's guidance to be conditional on the registry offering it. - A new daemon operation crosses nine layers:
jam-contract::Control,jam-manager,jam-wire(request, response, route), thejam-daemonhandler and route,jam-client,jam-ipc, the Tauri command pluscollect_commands!, both isolation allowlists, andjust bindings. 243 of the 255Controlmethods have default bodies (see the crosswalk), which lets each implementor adopt a method incrementally but also means a missing override compiles and fails at runtime with "not supported". - A new event kind is added to
EventKindincrates/jam-domain/src/event.rsand must be handled inapplyEventinapps/desktop/src/stores/daemon.ts. The events reference shows which kinds the desktop handles.
#Observations for the refactor
Facts about main that bear on a pluggable design. They are not a proposed design; Future directions compares the proposals.
- The catalog is the de facto manifest, but it is compiled into
jam-domain. A plugin boundary would need the catalog row, the adapter constructor, and the work-source constructor to live together. Today they live injam-domain,jam-host, andbins/jam/src/jamd.rsrespectively. build_hostdispatch is order-dependent and predicate-based. A registry keyed by(HostRuntime, HostTransport)would remove the ordering hazard, but attached delivery currently also depends onpeer.hostandsession.provider, not only on the transport.- Transport names have no single codec. Serde, the store, the manager, the host fingerprint, and the CLI each map names independently, with different alias sets.
- Per-transport rules in the manager (
apply_runtime_opts, thread-setting clearing, configuration validation, the default provider) could move into the catalog, because several of them already mirror catalog fields. For example, the catalog'sproviderdoc comment says it "mirrorsdefault_owned_runtime_providerin the manager." - Registries for shared processes are per provider.
CodexAppServerRegistryandCopilotSdkRegistryeach implement "one live process per Shared runtime host" separately. Claude Code and ACP have no registry and run one process per session. - Size concentrates the risk.
crates/jam-manager/src/manager.rsis about 58,000 lines, and its production code holds 95 of theHostTransportreferences outsidejam-host.crates/jam-manager/tests/lifecycle.rsis about 68,000 lines. These two files had the most changes of any code files in the 90 days before this snapshot. Codebase metrics breaksmanager.rsinto its regions. The largestimpl Managerblock (about 12,800 production lines, 162 methods) is mostly runtime, session, host, and workspace lifecycle: 93 of its method names contain one of those words. The next (about 6,000 lines) mixes builder setup, runtime defaults, room binding generations, and artifact transfer. TheControladapter adds about 7,600 lines, and human and room reads about 8,000.