Skip to content

Extension packaging, SDK and certification

New to extensions? Build your first extension is a 30-minute tutorial that writes, tests and installs one; this page is the reference behind it.

This guide covers writing, packaging, installing, upgrading, rolling back, removing and certifying a third-party extension. Installed packages run as separate processes that speak the subprocess protocol (decision B08) and are supervised under its runtime, trust model and schema evolution. Packaging, the SDK and certification are provisional, like the protocol: they may still change, and decisions 21–32 at the end are listed for review.

Code:

open-conduit extension new NAME [--dir DIR] [--module PATH] [--replace DIR] writes a package repository in DIR (default ./NAME) whose make check passes on its first run. It holds:

  • go.mod, with module PATH (default example.com/NAME), requiring the release of Open Conduit that ran the command, and tool directives for cmd/open-conduit and cmd/os so the tests and make certify need nothing installed. A development build requires the commit it was built from instead. --replace DIR builds against an Open Conduit checkout with a replace directive, starting from the checkout’s requirements and go.sum so the package builds from the module cache the checkout builds from. The command runs go mod tidy there, so go.sum is complete; with --replace, a tidy that can’t fetch what it wants is reported and the checkout’s files are kept.
  • main.go, with one operation an agent can call, NAME.hello, in the declare-once form, a certification fixture with a case for it, and a playbooks directory in the manifest.
  • main_test.go, a unit test on sdktest, and e2e_test.go, which installs the package in a real daemon with extensiontest and has a scripted agent call the operation.
  • A Makefile: check (formatting, vet, test -race, build and lint), package (the package layout in dist/NAME), lint (extension check on it) and certify. certify uses the Open Conduit go.mod requires; OPEN_CONDUIT_REF=<branch or tag> builds Open Conduit from source at that ref instead, and skips a ref older than the version go.mod requires.
  • .github/workflows/ci.yml, which runs make check and certifies against Open Conduit’s main and its latest release.
  • playbooks/NAME.md, a stub playbook naming the operation, and a README saying what to change first.

It refuses a name the manifest rules or Open Conduit’s own registrations rule out, and a directory that isn’t empty. TestExtensionNew runs the command against this checkout and runs the new repository’s make check. TestTutorialFirstExtension does the same with every file the tutorial shows saved over the scaffold’s, then runs its install and config set steps on a GitHub scope’s config, so the tutorial fails CI when it stops working.

An extension is a program that speaks protocol version 1 on stdin and stdout. In Go, protocol/sdk does that for you. Start one with sdk.New, register each handler, and call sdk.Serve. Registering a handler also declares it, so the manifest is derived from the registrations and can’t disagree with them:

type ForecastRequest struct {
City string `json:"city" doc:"The city to forecast, by name."`
Days int `json:"days,omitempty"`
}
func forecast(ctx context.Context, a extension.Authority, in ForecastRequest) (Forecast, error) { … }
func main() {
ext := sdk.New("weather", sdk.Version("0.1.0")).
Operation("weather.forecast", sdk.Handler(forecast), sdk.Guidance("Call `weather.forecast` before planning anything outdoors."))
if sdk.PackageCommand(os.Args[1:], ext.Manifest, nil, os.Stdout) {
return // package-manifest / package-certification
}
_ = sdk.Serve(ext)
}

sdk.New(name, opts...) speaks protocol version 1 and runs the command <name>-extension unless sdk.Command says otherwise. Set what can’t be inferred with sdk.Version, sdk.Summary, sdk.Permissions and sdk.KVVersion, or on ext.Manifest directly. Each registration returns the extension, so they chain:

Registration Declares
Operation(name, sdk.Handler(fn), opts…), or sdk.Wait(fn), sdk.Emit(fn) operations[] with its kind, summary (sdk.Describe), guidance (sdk.Guidance), commit (sdk.Commit, emit only), serves (sdk.Serves, handler only) and input
Route(handler, h, opts…) routes[], with samples when h is an extension.RouteSampler, binding (sdk.RouteBinding) and config (sdk.RouteConfig)
WorkKind(kind, opts…) work_kinds[], standing (sdk.Standing), trigger (sdk.Trigger(fn)), timed (sdk.Timed: a dry run starts none of its Work)
Task(name, every, fn), Check(name, fn, opts…) tasks[] with its interval, checks[] (sdk.Deep)
IngressType(type, lookup, families…), Family(families…) extensions[] with ingress, and families[]
Recurrence(kind, fn), DependencyFilter(fn) recurrences[], dependency_filter

An operation’s fn takes a typed input: func(ctx, extension.Authority, In) (Out, error) for sdk.Handler, returning an extension.WaitPlan or extension.EmitPlan for the other two. The SDK decodes the input’s data into In strictly. A missing required field, a field In doesn’t have, or a value of the wrong type is refused with bad_request and a message naming the field, before fn runs. Out is sent as the output’s data, under the schema <operation>.v1 unless sdk.OutputSchema names another. The JSON Schema of In (sdk.SchemaOf) becomes the operation’s input in the manifest: its fields by their json names, required unless tagged omitempty or omitzero or a pointer, with a doc tag as the description. The host carries input but doesn’t check calls against it yet. Use domain.Payload for In or Out to handle the raw payload yourself.

A registration that can’t be served panics when the extension is built: sdk.Commit on an operation that isn’t an emit, or an input that isn’t a struct or a payload. The surfaces with no registration method (Configure, Validate, Webhooks, Executors, Providers, Sink, Actions, Render and the rest in the table below) are set as fields, with their declaration on ext.Manifest.Provides. A literal sdk.Extension with a hand-written Manifest works too, and is what older packages use:

func main() {
if sdk.PackageCommand(os.Args[1:], manifest(), certification(), os.Stdout) {
return
}
_ = sdk.Serve(&sdk.Extension{
Manifest: manifest(),
Configure: configure, // entries → ingress sources, webhook paths
Validate: validate, // config objects of your declared types
Ingress: func(name string) (extension.Ingress, bool) { … },
Routes: map[string]extension.RouteHandler{"acme": route{}},
Operations: []extension.Operation{…}, // toolbelt operations
Executors: map[string]extension.Executor{"acme": executor{}},
Tasks: map[string]func(context.Context, *sdk.Host) error{"acme-sync": sync},
})
}

The SDK serves the public Go contracts unchanged, so code written for the in-process interfaces (extension.Ingress, extension.Executor and its optional controls, extension.RouteHandler, extension.Operation, extension.ResourceProvider, observability.Exporter, extension.Setup) runs in a subprocess as it is. The first-party Slack extension and shell executor run this way with no change to their packages (extensions/slack/pkg, extensions/shell/pkg).

sdk.Extension field Manifest declaration Wire methods
Configure, Migrate always configure (KV migration first when kv.stored < kv.declared)
Validate connection, extension, route (config), executor or resource types, and route bindings (routes[].binding, kind binding) validate
Ingress extensions[].ingress ingress.push, ingress.reconcile
Webhooks webhooks webhook.handle
Routes, Triggers routes (samples for a handler that is an extension.RouteSampler), work_kinds route.match, route.samples, work_kind.trigger
Recurrences recurrences recurrence.next
Operations, Filter operations (entry_gated to offer them only with an entry), dependency_filter operation.*, emit.commit, dependency.filter
Executors executors (pool: "jobs" for an executor whose runs count against settings.job_concurrency) executor.*
Providers resources provider.*
Sandboxes sandboxes sandbox_provider.*
Containers containers (forward for an extension.ContainerForwarder) container_provider.*
Tunnels tunnels tunnel.run, tunnel.url (E→H), validate kind tunnel (Validate)
Tasks, Checks, CheckRuns (a check that reads the configuration view or reports its own status, code and items) tasks, checks task.run, check.run
Exporter exporters telemetry.export
Sink permission telemetry.sink telemetry.sink
RunEnvironment run_environment run.environment
Annotate annotate work.annotate
Actions, Act actions work.actions, work.act
Render render event.render
Target (TargetResolvers) targets target.resolve
OnSegmentEnd (SegmentEnded) segment_end segment.ended
Setups setup (first_run for the setup that builds the default configuration) setup.run with the setup.* callbacks
Upcasters schemas applied before extension code sees an older payload
— extensions[].settings, extensions[].describe none: the host reads an entry’s safe settings and describe lines from its config
— fields on connections[], extensions[], routes[], executors[] and resources[] none: open-conduit adapters and the config editor show each type’s described fields

Reaching the host:

  • sdk.Host is passed to Configure, Migrate and tasks, and is available inside any handler through sdk.HostFrom(ctx). It has KV (Get, List, Commit, Update), Accept (derived Events with records), Deliver (your own receiver’s deliveries), state reads (Works, Events), Secrets and ConnectionSecrets, and Artifact, Authorize (scope.authorize, whether one of its scopes may read or file in another), WaitTarget (wait.target, with a wait operation) and RenderOwned (event.render_owned, with an executor). With the matching permission it also has Capture (telemetry.capture), Probes and Probe (probes), ConfigBase, ConfigEdit, ConfigValidate and ConfigTrace (config.read), Act and Actions (action.submit), which invoke and list actions for a person the package identified on its own surface, and SettleHold (work.start_gate), which settles a hold its start gate placed.
  • sdk.Store is storage.Store over the host. It lets code written against a coordination transaction run unchanged. KV reads carry version tokens, and one Transact’s writes commit as a single kv.commit batch whose preconditions are every key it read. A concurrent change to one of those keys is storage.ErrConflict. State reads are the scoped state.* methods. Every core write returns sdk.ErrReadOnly; an Event is instead returned from the transaction with sdk.EmitInTx(tx, scope, parent, event), which the host accepts with the transaction’s writes as a kv.commit Emission (a root Event when parent is empty; one already accepted is answered with added false and not emitted again; a causal refusal makes Transact return an error wrapping inproc.ErrCausalLimit after the rest commits), and sdk.EventScopeInTx reads the scope an Event was accepted into. Conflict detection covers keys read, not prefix ranges, so it is weaker than the host’s serializable transactions. The Slack projector and tools run on it.
  • sdk.Artifacts is storage.Artifacts over artifact.*. It writes in acknowledged chunks; Open and Delete are host-only. The shell executor writes its logs through it.

Other rules:

  • Write nothing but protocol messages to stdout; sdk.Serve moves os.Stdout to stderr for you.
  • Log to stderr. The host keeps a bounded log at <state>/logs/extensions/<name>.log.
  • Exit when stdin closes.
  • Keep durable state in host KV, in recovery metadata, or in the private state directory (Server.StateDir(), <state>/extensions/<name>).

Any language works: the protocol is newline-delimited JSON-RPC 2.0, and the certification runner checks a non-Go extension the same way.

sdk/typescript is the same SDK for TypeScript on Node 22.18 or later, with no runtime dependencies. It covers serving, the host client and declare-once. extension(name, options) starts one, each registration declares itself in the manifest, and main() answers package-manifest and package-certification or serves on stdin and stdout:

import { extension, OperationError, t } from "@open-conduit/sdk";
const ext = extension("weather", { version: "0.1.0" })
.operation("weather.forecast", {
input: t.object({
city: t.string({ description: "The city to forecast, by name." }),
days: t.optional(t.integer()),
}),
guidance: "Call `weather.forecast` before planning anything outdoors.",
handler: async ({ city, days = 1 }, { host, authority }) => {
if (city === "atlantis") throw new OperationError("unknown_city", `no forecast for ${city}`);
return { city, days, summary: "rain" };
},
});
void ext.main();

Schemas are built with t (string, enum, literal, integer, number, boolean, array, object, record, optional, nullable, any). One value is both the JSON Schema the manifest declares and the type the code receives, so neither needs a type assertion. An operation’s input is decoded as strictly as the Go SDK decodes it: a missing required field, a field the schema doesn’t have, or a value of the wrong type is refused with bad_request before the handler runs. Use t.object({}) for an operation with no input. The output is sent as <operation>.v1 unless output names another schema. Throw OperationError to refuse with your own code; anything else thrown crosses as internal, with the message kept in the extension’s log.

Registration Declares
operation(name, { input, summary, guidance, serves, output, handler }) operations[], kind handler, with summary, guidance, serves and input
entryType(type, { config, validate, configure }) extensions[]: a scope entry type. A config that config refuses, or that validate throws on, is invalid before the daemon applies it, and configure receives each generation’s entries with their config typed
check(name, fn, { deep }) checks[]; fn returns the detail line and throws to fail
task(name, intervalSeconds, fn) tasks[]
family(...families) families[], for Events derived with host.accept

The Host client (ctx.host) offers get, list, commit, update, accept, events, works, secrets, connectionSecrets, artifact and waitProgress, plus call(method, params, resultSchema) for any other method. KV values are bytes (Uint8Array), or strings stored as UTF-8. onMigrate upgrades the extension’s KV namespaces to kvVersion. Everything the host sends is decoded through the schemas in src/wire.ts, which ignore fields a newer host adds, and a malformed message is refused rather than passed on.

main() moves stdout to stderr while it serves, so console.log can’t corrupt the protocol stream, and it exits when stdin closes. The manifest’s command must be an executable file inside the package, so bundle the extension to one file with a #!/usr/bin/env node line and make it executable. protocol/examples/weather does this with esbuild in scripts/package.mjs, as CommonJS, and writes { "type": "commonjs" } as the package’s own package.json. Node reads an extensionless file as the nearest package.json says, so without it a "type": "module" one above the package (the project’s own, when the package is built inside it) makes Node load the bundle as ESM, and package-manifest fails with require is not defined in ES module scope. The daemon runs the command with its own PATH, so env node is the first Node on that PATH: under the background service, the PATH service install recorded from the installer’s shell. After changing which Node you use (with a version manager, say), run service install again, and check the extension with open-conduit doctor. Its unit tests serve the extension over a pair of in-memory streams, with the SDK’s Conn standing in for the host, and make certify certifies its package.

This first step doesn’t include wait and emit operations, routes, ingress, executors or the provider kinds (resources, sandboxes, tunnels), an in-process test host like sdktest, a TypeScript extension new scaffold, or an npm release. src/wire.ts is written by hand. TestTypeScriptWireMatchesGo fails when a field there is no longer a JSON field of the Go type it names, or when Go can send null for a field the SDK doesn’t accept null for.

  • protocol/examples/notes: every extension surface. It has ingress with a webhook receiver, a route with a trigger, operations including a committed emission, a recurrence, tasks, a resource provider, a telemetry exporter, annotations, a setup flow, validation, three releases with schema evolution, and a KV migration. It declares every permission: a task records telemetry events and runs the host’s probes, another builds, validates and traces a config candidate, it receives the host’s telemetry events as a sink, and its entry type declares settings, describe lines and the fields open-conduit adapters notes shows.
  • protocol/examples/notes fixture profile: the declare-once form. TestFixtureManifestIsDerived checks that its derived manifest equals the hand-written one.
  • protocol/examples/runner: an executor with detached jobs, inject, interrupt, stop and forget.
  • protocol/examples/weather: an extension in TypeScript, with one operation answered from its scope’s config and a lookup count in host KV.
  • extensions/slack/pkg and extensions/shell/pkg: real first-party workloads packaged, each the extension itself served through inprocsdk.
  • extensions/github/pkg: the GitHub extension, unchanged, served by protocol/sdk/inprocsdk. It passes certification against the GitHub fake (githubtest), so the in-process contract crosses the boundary without loss, and it is how GitHub runs: bundled with the release, with its setup probes and its work-issue playbook.
  • extensions/sentry and extensions/pagerduty: sources built only on protocol/sdk, as a third party would build one. Each verifies its service’s signed webhooks, reconciles against its API, opens one Work per issue or incident and binds its operations to the calling Work’s item. Each fake (sentrytest, pagerdutytest) serves both the package’s tests and its certification fixture.
  • extensions/codex/pkg: the Codex CLI executor, which exists only as a package, is served by open-conduit extension-run codex and is bundled with the release (Codex executor).

protocol/sdk/inprocsdk serves any extension written against extension/inproc as a package, without changing it. It is the only way such an extension runs: the host composes only packages and never registers an extension in process, and make boundaries keeps extension/inproc to the extensions and the SDKs that serve them. Each first-party extension on it has a pkg beside it whose Main serves it this way (served by open-conduit itself). inprocsdk.New(x, base) derives the manifest from the extension’s surfaces (config types, routes, Work kinds, recurrences, executors with their pools and controls, tasks, wait target kinds, and the webhook, render, annotate, actions, run-environment and segment-end flags). base supplies what the surfaces can’t say: the version, summary and command, the Event families and schemas, each operation’s kind (a toolbelt registration exists only once started), Work kinds its routes create without a policy, and its doctor checks’ names, by position. A task not already named after the package is prefixed with it (projection becomes github-projection), as the manifest requires.

The extension starts at the first configure, with an inproc.Host whose services are the host’s methods: sdk.Store for its store, secret.resolve, ingress.deliver, scope.authorize, wait.target, wait.progress, event.render_owned, action.submit, action.list, work.settle_hold and telemetry.capture. In-transaction acceptance (AcceptInTx, AcceptDerivedInTx) returns the Event from the transaction with sdk.EmitInTx, so the host accepts it with the transaction’s writes. The answer keeps the in-process contract as far as the wire allows. An Event already accepted (read through state.event, or emitted earlier in the transaction) is answered as it stands with added false and isn’t emitted again, so a caller’s dedupe holds. A new one is answered with added true and Sequence 0, because its sequence is assigned only at commit, so records are keyed by its ID. A causal bound is known only at commit: the host records it, the rest of the batch commits, and Transact returns an error wrapping inproc.ErrCausalLimit. EventScope reads state.event. Timers go through timer.arm and timer.cancel when the manifest declares recurrences, CancelWork through work.cancel when it declares Work kinds, and Config and Probes through config.* and probe.* when it declares the config.read and probes permissions; otherwise they are nil, as in process. Each configure answers the extension’s sources, and as webhook paths those it has a webhook handler for. Its per-scope action providers are offered as one: the actions of every provider on the target’s scope, with an invocation going to the first that offers it, and the panels of those that are extension.PanelProviders (view.render, when the base manifest declares panels). The operation and start gates of its toolbelt registration answer operation.gate and work.admit when the base manifest declares operation_gate and start_gate (with their permissions), since a registration exists only once started. An inproc.Exporting extension’s exporter answers telemetry.export (the bridge declares exporters), and an inproc.Sink receives telemetry.sink when the base manifest has the telemetry.sink permission. Bridge.Serve runs it on stdin and stdout.

A package is one directory, named after the manifest:

acme/
open-conduit-extension.json manifest (required); "name": "acme"
acme-extension the manifest's command[0], relative and executable
playbooks/ the manifest's playbooks directory, if declared
certification.json the conformance runner's fixture (recommended)
… anything else the command needs

open-conduit extension inspect DIR checks a directory offline. It checks the strict manifest rules, a relative command inside the package that exists and is executable (unless the command is open-conduit extension-run <name>, served by open-conduit itself), and the declared playbooks directory. A package is self-contained: install copies it, resolving symlinks.

open-conduit extension check DIR [--json] [--allow NAME,…] lints a package without starting it or a daemon. It reports every problem it finds rather than the first, and each comes with what to change and a link to Troubleshooting. The exit status is 1 when any finding is an error; warnings don’t stop install or start. It checks:

  • the manifest: the strict rules, each broken rule on its own line, and a command inside the package that exists and is executable;
  • compatibility with this binary: a protocol version in common, no field from a newer Open Conduit, a command built for this OS and architecture, and no name Open Conduit registers itself;
  • the playbooks: the declared directory exists and holds playbooks, and every `ns.op` or "ns.op" a playbook names, in a namespace the package or core owns, is an operation the package declares or core registers, an event family the package declares, or a setup probe the host runs. Names in other namespaces, such as config paths and file names, are left alone. A package’s own setup probes run inside its process, so the check can’t see them; name them with --allow;
  • certification.json: it parses, and an inline fixture has an ingress case when an extension type takes ingress, an executor case naming a declared executor when there is one, and route and operation cases only for declared handlers and operations. A fixture printed by a command is left to certify.

Run it in the package’s CI before certify. The setup package’s own make check runs it, with --allow setup.fs.list,setup.tools for its probes.

Packaged Go binaries also answer package-manifest and package-certification (sdk.PackageCommand), so the manifest has one source of truth in code. make packages writes the first-party packages and builds the examples into dist/packages/<name>/ this way.

A first-party package builds no executable of its own. Its manifest’s command is exactly ["open-conduit", "extension-run", "<name>"] (protocol.HostRunCommand), and the host runs that with its own executable, so the release stays one download and one build while each extension is still its own supervised process (#428). The package directory holds the manifest, the certification fixture and any playbooks, which open-conduit extension-run NAME package-playbooks DIR writes (the trackers share work-tracker-issue; identical playbooks from several packages are one playbook, not a conflict). open-conduit extension-run NAME [ARGS…] serves the named extension over protocol version 1, or answers its package-manifest, package-certification and fixture subcommands. An extension that runs itself again (Codex’s detached segments, its fake CLI) gets the command to do it from sdk.Self, which the multicall sets. open-conduit extension-run with no name lists the extensions it serves: bitbucket, claude-code, cloudflared, codex, docker, git, github, human, introspection, jira, linear, ngrok, observe, pagerduty, posthog, schedule, sentry, shell, slack, srt and ssh today. Any other command word stays a file in the package, and a manifest naming open-conduit with any other arguments is refused. The command always runs the host’s executable: a file the package ships under that name is never run, so nothing escapes the executable, platform and digest checks every other command gets. Discovery takes such a package’s manifest from the host (open-conduit extension-run NAME package-manifest, read once per process; the daemon is that executable, so it answers in process rather than running itself), not the copy written at install, so upgrading open-conduit updates an installed host-run package without a reinstall; a host that doesn’t serve the name refuses the package (ErrNotServed). A third-party package keeps its own executable, as before.

The release archive carries packages as well as binaries, under extensions/<name>/ beside open-conduit and os. The daemon discovers them there as bundled packages, and a package of the same name installed in <config dir>/extensions replaces the bundled one. Today the archive bundles twenty: setup, the setup assistant, which a fresh install needs for its first-run configuration, and nineteen that open-conduit itself serves: claude-code, the Claude Code executor, codex, the Codex executor, posthog, the PostHog telemetry exporter, human, human decisions, observe, observed sources, the trackers linear, jira and bitbucket, schedule, introspection, slack, github, the shell executor, the srt sandbox provider, the git workspace provider, the docker and ssh container providers and the ngrok and cloudflared tunnels. Each first-party extension moved out of process by leaving bootstrap’s in-process list, which #508 removed, and gaining a pkg/ package and a runnable entry marked bundled, which open-conduit extension-run --bundle and so scripts/bundle-packages.sh lay out; offline config validation (config check, ValidateConfig) includes bundled packages even with no config file, so its types still validate. The integration drills lay the same packages out beside their daemon binary through the same command, and TestReleaseBundlesThroughTheBinary checks that scripts/bundle-packages.sh bundles through it, so the two lists can’t drift.

  • Source. setup lives in its own repository and module, built on protocol/sdk like any third party’s, and is pinned in this repository’s go.mod with a tool directive. Nothing imports it. The other nineteen live in this repository and run as open-conduit extension-run NAME, so each bundled directory is a manifest and certification with no binary.
  • Build. scripts/bundle-packages.sh OUT [GOOS_GOARCH…] builds setup from the module cache for each platform and writes its manifest, certification and playbooks beside the binary, and writes each package open-conduit serves from a host build’s extension-run NAME package-manifest. GoReleaser runs it before building and adds each platform’s directory to that platform’s archive. make packages runs it for this host into dist/packages/setup, and make certify certifies it with the others. make release-snapshot checks that extension list shows setup and every package in the Makefile’s BUNDLED_PACKAGES (the runnable packages but sentry and pagerduty) as bundled, and that codex ships no executable.
  • Install and upgrade. install.sh and open-conduit upgrade replace extensions/ beside the binary with the release’s as a whole (upgrade stages it, then swaps it in after the binaries, so a failed swap leaves the old one). Packages installed in the config directory are never touched. The Homebrew cask keeps the whole archive in its directory, so the daemon finds extensions/ beside the linked binary.
  • Updating the pin. Bump the version in go.mod (go get github.com/open-conduit/extension-setup@VERSION). The package’s own CI certifies it against the latest release and main before it is tagged.
What Range Checked
Protocol The manifest’s protocol lists every version the package speaks. This host speaks protocol.Versions ([1]). They are compatible when the lists intersect; the highest common version is used. at install (incompatible_protocol) and at every start (initialize refusal)
Host build No host-version field. The protocol version changes only for a breaking change, and the host then keeps speaking the previous one for at least one release. An additive surface (a new method or manifest field) arrives in a release without a version bump, so a package that uses one needs that release of Open Conduit or later. An older host does not know the field and refuses the package as needing a newer Open Conduit. at install and at every start (needs_newer_host)
Own release version is informational: shown in list, doctor and reports. —
KV layout kv_version never goes down. A newer release migrates from the stored layout at configure. at update (refused) and at start (kv_downgrade)
Payload schemas schemas[].versions lists what the release reads; the last is what it writes (schema evolution). every call (deterministic read gate)
Executor capabilities Bindings requires mechanical labels; a package declares exactly what it implements. at config validation

Installed packages live in <config dir>/extensions/<name>/, beside config.json. The daemon reads them only at start, so every change takes effect at the next open-conduit serve. Directories starting with . belong to the installer: .staging and .previous/<name>.

  • open-conduit extension install DIR checks the layout and protocol compatibility. It refuses, atomically, any name that conflicts with a first-party registration or another installed package. It then copies into a staging directory and renames into place, so a failed install changes nothing. A package that claims a name the core registers (a built-in route handler such as manual, say) is refused (registration_conflict); one named after a bundled package (slack, shell, claude-code, …) replaces it.
  • open-conduit extension install ARCHIVE [--sha256 HEX] installs a release archive instead of a directory. ARCHIVE is a path or an http(s) URL to a .tar.gz holding one package directory (acme/open-conduit-extension.json, acme/acme-extension, …). The archive is checked against --sha256 when given, unpacked into the installer’s staging directory and then installed exactly as a directory is. It is refused (invalid_archive) when it comes over plain http without --sha256, when an entry’s path escapes the package directory, when it holds a link or special file, or when it holds more than one top-level entry (macOS ._* metadata is skipped). Publish one archive per OS and architecture.
  • Any install or update refuses a package whose command is an executable built for another OS or architecture (wrong_platform), naming both platforms. A command that is a script is not checked.
  • open-conduit extension update DIR (or ARCHIVE [--sha256 HEX]) does the same for an installed name. A lower kv_version is refused. The replaced release is kept as .previous/<name>.
  • open-conduit extension rollback NAME swaps the kept release back. If the newer release already migrated the extension’s KV layout, the daemon refuses the older one at start with kv_downgrade, because there is no automatic downgrade. The remedy is to restore a backup taken before the update (backup bundles).
  • open-conduit extension remove NAME is refused while the config at --config still uses the package’s types (it validates the config without the package). --force overrides. Removal deletes the package and its kept release, never its durable state. Its KV namespaces and recorded layout stay in the coordination store, and <state>/extensions/<name> and its log stay on disk, so reinstalling the same or a newer release resumes from them. To purge, delete those directories after removal.
  • Install, update and rollback print the permissions the installed release declares, each with what it allows, so the operator sees what the package may use beyond its own surfaces.
  • open-conduit extension list shows installed packages, their protocol versions, what they provide, their permissions, the release kept for rollback, and any discovery or conflict problems. inspect shows a directory’s permissions before install.
  1. open-conduit extension certify NEW_DIR (below).
  2. open-conduit backup create DEST with the daemon stopped, if the new release raises kv_version.
  3. open-conduit extension update NEW_DIR.
  4. Restart the daemon. At start the old process is gone; the new one migrates its KV at configure, and payloads it cannot read are skipped deterministically. There are no rolling upgrades.
  5. Check open-conduit doctor (the extensions check). On trouble, run open-conduit extension rollback NAME and restart. If the new release migrated KV, the rollback is refused at start and you restore the backup instead.
  1. Remove the config entries, bindings and routes that use the package; config check confirms.
  2. Apply the config to the running daemon. Work bound to the package’s executor should be finished or cancelled first; detached executor jobs keep running until they end.
  3. open-conduit extension remove NAME, then restart the daemon.

At start, serve:

  1. discovers the packages and refuses a conflicting set atomically;
  2. starts each behind its scoped service, before the configuration opens, so the packages’ validators check it. The first configure carries an empty generation; the real one follows once the configuration and ingress exist;
  3. registers, for bundled and installed packages alike:
    • config types with validate-backed validators;
    • route handlers, Work kinds and recurrences;
    • executors (their binding config validated by the package);
    • resource providers, reconciled with the daemon’s owner status;
    • toolbelt operations;
    • the ingress sources each configure returns;
    • webhook paths on the webhook listener;
    • declared tasks at their intervals;
    • doctor checks, each under its own name beside the first-party ones (deep checks under --check-secrets), with the extensions check reporting the package’s own health;
    • Work annotations, prompt rendering for the package’s own families, and telemetry export.

It then sends configure again on every config apply and every tunnel URL change. Ingress registrations are immutable for one daemon instance, as for first-party ingress, so a renamed source needs a restart. Nothing in core branches on a package: every decision reads the manifest.

Secrets reach a package only through secret.resolve. It returns the secret names selected by the package’s own entries in a configured scope, or, with connection set, the secret names of a connection one of those entries names (B05). config check offline knows the installed packages’ types, and each package checks its own opaque config there too: it is started for validate alone, without a coordination store, on a throwaway state directory, and stopped afterwards (doctor checks). open-conduit init TYPE offers installed packages’ setups by starting the package for the duration of the flow, without a coordination store.

A package’s process starts with the sanitized baseline only (PATH, HOME, USER, LOGNAME, SHELL, LANG, LC_*, TZ, TMPDIR, TERM), so no credential or Open Conduit setting reaches it through its environment. A package that needs a variable of the daemon’s own outside any binding (the ssh agent a container provider reaches every remote with, DOCKER_HOST for the docker CLI, NGROK_AUTHTOKEN for the ngrok tunnel) is granted it by the operator, by package name, in the configuration:

"settings": { "extensions": { "ssh": { "pass_env": ["SSH_AUTH_SOCK"] } } }

The host checks the names with the rules of a binding’s pass_env (pass_env_reserved for OS_* and OPEN_CONDUIT_*, pass_env_secret for a variable an env: secret reference names), and an included folder’s fragment has no settings, so a checkout can’t grant one. A name no installed or bundled package has is a config check warning, not an error, so a config survives removing a package. When it starts the package, the host adds the named variables it has set to that package’s process environment, on top of the baseline. The grant reaches that package alone, and never through a run: a binding’s pass_env reaches its runs (ExecutionContext.HostEnv) and its resource’s pass_env the provider’s acquisitions (ResourceAcquisition.HostEnv), each apart.

Every start takes the grant then current: a daemon start (from the committed generation, or the config file on the first start, before the configuration opens), a restart after a crash, an offline start for config check or doctor (from the config being checked) and a setup run (from the config file). A running package keeps the environment it started with, so a change applied with config apply reaches it at its next start, a serve restart or its restart after a crash. The extension started log line and the doctor extensions check name the variables each package was granted (names only, never values), those granted but not set in the daemon’s environment, and a grant changed since it started (extension_grant_pending).

An installed package’s work_kind.trigger, recurrence.next and dependency.filter run through the bounded in-transaction calls, with the same fallbacks.

A package’s playbooks join the Claude Code plugin the daemon writes at start, beside the first-party ones. Each <name>.md file in the manifest’s playbooks directory is the playbook (a skill) of that name; other files are ignored. {{bin}} in the text becomes how the agent runs Open Conduit (open-conduit in a turn). A package playbook never replaces a first-party one of the same name: it is logged and left out. init --with claude starts on the first-run setup’s playbook, the one named after its setup type, from the package.

Every refusal and failure gets a stable code, a summary, a remedy and a link to Troubleshooting (extproc.Diagnose). They appear in the daemon log, the extensions doctor check and the CLI. They never include config values or secrets.

Unsupported capabilities are refused at config validation: a binding requiring inject against an executor that does not declare it is unsupported_capability at the binding’s requires path. An undeclared surface is never called, and callbacks from one are refused with undeclared.

What each code means and what to change. extension check gives the codes marked check before install; the others come from install, start, config check or doctor.

Code What is wrong What to change
invalid_package (check) A manifest rule is broken, the command is missing or not executable, or the playbooks directory is missing Run extension check DIR: it names each rule and the change for it
incompatible_protocol (check) The package and this host share no protocol version List a version this host speaks in protocol and build against an SDK release for it, or upgrade Open Conduit
needs_newer_host (check) The manifest has a field this Open Conduit doesn’t know Correct it if it’s a typo; otherwise upgrade Open Conduit to the release that added it, or install a release of the package built for this one
wrong_platform (check) The command is built for another OS or architecture Build it for this platform, or install the release archive built for it
registration_conflict (check) The package claims a name Open Conduit or another package registers Rename what the package declares; names the core registers (manual, core, the builtin operations, …) can’t be taken. A bundled package’s name isn’t a conflict: the installed package replaces it
unknown_operation (check) A playbook names an operation nobody declares Declare it under provides.operations, correct the name, or --allow it if it is one of the package’s own setup probes
no_playbooks (check, warning) The playbooks directory has no <name>.md Add one, or drop playbooks from the manifest
no_certification (check, warning) No certification.json Add one so certify can check each surface (fixtures)
invalid_certification (check) certification.json doesn’t parse or has neither a fixture nor a command Fix it against protocol.CertificationFile
fixture_missing_case (check) The fixture lacks a case certify requires, or a case names something undeclared Add an ingress or executor case, or name a declared handler or operation
invalid_archive The release archive was refused Check the URL or path and its --sha256; an archive is a .tar.gz holding one package directory of regular files
install_state Installing over an installed package, or changing one that isn’t installed extension list shows what is installed; use update to replace
manifest_mismatch The process announced a different manifest Binary and manifest are from different releases; reinstall
kv_downgrade Older KV layout than recorded Roll forward, or restore a backup
start_failed The command could not start Check that it exists and is executable for this platform
handshake_failed Exited or silent during initialize/configure Read <state>/logs/extensions/<name>.log; only protocol on stdout, logs on stderr
configure_refused Refused the config, or registered names outside its namespace config check and the log
binding_refused A route handler refuses the binding its route binds Change the binding’s executor, operations, grants or config to what the message asks, or route to another handler
extension_restarted (warn) The process crashed and was restarted The log
extension_check_failed (warn) A declared doctor check failed The check’s detail

There are two harnesses. Use sdktest for a handler’s logic: it runs in milliseconds and needs nothing built. Use extensiontest to see the package working in the daemon: configured from a real config, routed, run by an agent and shown to the operator.

protocol/sdk/sdktest serves your sdk.Extension on an in-memory host behind a real jsonrpc pipe, so every call crosses the wire as it would in the daemon. sdktest.New(t, ext, opts...) initializes it with a temporary state directory.

  • Seeding (options): WithScopes, WithWorks, WithExecutions, WithWaits, WithEvents(scope, events...) (accepted as causal roots), WithKV, WithKVVersion (the stored layout, so Configure runs Migrate), WithSecrets and WithConnectionSecrets, WithAuthority (what Operation calls carry) and WithClock. Pass conformance.NewClock() and advance it; the default clock is fixed at sdktest.Start.
  • Calling in: sdktest.Call[Out](ctx, h, name, in) calls a declared operation with in encoded as its input and decodes the output into Out, so a test never builds a payload by hand. Operation(ctx, name, input) calls a declared operation by its kind and returns an OperationResult: a handler’s output, a wait’s plan, or an emission’s accepted Event with its commit’s output (the records are written to KV). Configure, Route, RunTask, Check and CheckRun cover the other common surfaces, Tunnel holds a tunnel.run open and collects the URLs the package reports, and Call reaches any other method (webhook.handle, event.render …).
  • Inspecting: KV(namespace), Events() (seeded, derived, delivered and emitted), Commits() (each applied kv.commit batch), Artifacts(), Captured() and KVVersion().

The host answers the scoped methods the way the daemon does. KV values carry the same version tokens, a stale check is storage.ErrConflict, and another extension’s namespace is unauthorized. event.accept needs a parent Event in the same configured scope, deduplicates by source and delivery ID, and writes its records only when the Event is new. A kv.commit batch’s Emissions are accepted with its writes, or, on a failed check or a refused Emission, nothing is. ingress.deliver runs the delivery through the source’s adapter (ingress.push) from the last Configure. State reads and secrets see only configured scopes. A method the manifest does not enable is refused as undeclared. It does not enforce causal depth and rate limits, and it does not serve timers, Work control, probes or config.* (they answer unsupported). protocol/examples/notes is tested this way.

h := sdktest.New(t, acme.Build(), sdktest.WithKV("acme.index", "a", []byte(`{"text":"hi"}`)))
got, err := sdktest.Call[acme.Note](ctx, h, "acme.lookup", acme.LookupRequest{ID: "a"})

To see the package working in the real daemon, its repository’s tests use extensiontest, which needs only this module as a dependency. Pin the two commands it builds with tool directives, so go mod tidy keeps what they need and the build works from the module cache:

tool (
github.com/open-conduit/open-conduit/cmd/open-conduit
github.com/open-conduit/open-conduit/cmd/os
)

A test asks the question a package’s author has: when an agent calls my operation, does it work? It names the package, scripts the agent, starts a Work and checks what came back:

func TestForecast(t *testing.T) {
service := httptest.NewServer(fakeWeatherService)
defer service.Close()
d := extensiontest.Daemon(t, extensiontest.Package("."),
extensiontest.Secret("api_key", "test-key"),
extensiontest.Entry("weather", map[string]any{"service": service.URL}))
agent := d.Agent().
Call("weather.forecast", map[string]any{"city": "Leeds"}).
Call("weather.report", map[string]any{"summary": "rain"}).
Ends("completed")
work := d.Start("weather.check", nil)
if r := agent.Result(1, "weather.forecast"); r.IsError || !strings.Contains(r.Text, "rain in Leeds") {
t.Fatalf("forecast: %+v", r)
}
d.WaitWork(work.ID, "completed")
reported := d.Events("weather.reported")
// ...
}
  • extensiontest.Package(src) is the package under test: a built package directory (one with an open-conduit-extension.json), or its Go main package, which the harness builds as make packages would, asking the binary for its manifest and certification and copying the manifest’s playbooks from src or the module’s root. A directory with exactly one main package under cmd/ builds that one, so Package(".") works from a package repository’s root.

  • The generated scope. With a package and no Options.Config, the daemon runs one scope, test (extensiontest.ScopeID), made from the manifests:

    • an entry for each extension type, named after the type, with config {} unless extensiontest.Entry(type, config) gives one;
    • a binding running the scripted agent, granted every operation the manifests declare plus read_context, wait and declare_outcome. It is a shell binding on the CLI toolbelt by default, so testing an operation needs no LLM executor; extensiontest.UseFakeClaude() makes it a claude-code binding (the MCP toolbelt, inject and resume) running FakeClaude instead;
    • a route for each route handler they declare, and a manual route last.

    Before the daemon starts, the generated config goes through config check with the packages installed. When it is refused, the test fails with each refusal, what to change (an Entry, or the whole config) and the generated config. A package directory that can’t be built or whose manifest is refused fails the same way.

  • extensiontest.Secret(name, value) adds a secret to the scope’s secret_refs and selects it in every generated entry, so the package’s secret.resolve returns value as it would the operator’s. Use it to point the package at an httptest fake of its service.

  • d.Agent() is the scope’s scripted agent. Call(operation, input) adds a toolbelt call to the turn being scripted, Ends(state) ends it, and Turn() starts the next. By default each turn is a launch of the shell binding: it makes its calls with the os CLI (os call for a package’s operations; os context, os wait, os emit, os work create and os outcome for the core ones) and ends with state as the attempt’s outcome: completed, failed or handoff, declared with os outcome, or waiting after a wait call, so the Work parks and the next turn plays in the attempt its wake starts. For one release, finished (what the scaffold’s test used to end on) is taken as completed, with a deprecation note in the turn’s output. The other Claude Code states (building, review, help, done) keep a Work open, which a shell turn can’t do without a wait, so they fail the turn and point to UseFakeClaude. With UseFakeClaude, state is the structured status the fake Claude Code turn ends with. The agent plays one turn each time the daemon launches it, for any Work, so script it before starting the Work. Played(n) waits for turn n and returns its input (the shell turn’s OS_INPUT_FILE, or the message the fake Claude turn was started or resumed with) and each call’s result; Result(n, operation) is one call’s result, its Text and whether it was a refusal (IsError).

  • Work and Events. d.Start(kind, input) creates Work of a kind in the scope, run by the agent, with input as its context. d.Submit(event) submits an operator Event, as open-conduit events emit does. d.WaitWork(id, state) and d.Events(type) wait, up to extensiontest.Deadline, for the Work to reach a state and for at least one Event of a type. None of them sleeps for a fixed time.

  • The daemon. extensiontest.Daemon(t, options...) builds open-conduit and os once per test binary, at the version go.mod pins (with -race when the test runs under it). It keeps them, and each package it builds, in a cache under the user cache directory (open-conduit/extensiontest, or the directory OPEN_CONDUIT_EXTENSIONTEST_CACHE names), keyed by what went into the build, so a later run with the same source reuses them and a run leaves nothing in the temp directory. The next run removes an entry no run has used for a day, and any binaries beyond the four most recently used (unless used within the hour). It starts serve with its state in a temporary directory and a short poll interval, and returns once the management API answers. Client() is the operator’s *client.Client: assert what an operator sees with Pages, Actions and Panels, and what was recorded with Events and Work. Until(what, timeout, fn) polls a condition; Restart() kills the daemon and starts it again on the same state. Close, which the test’s cleanup runs, kills it and fails the test on a race report in its log or on a process it started that is still running; the log is printed when the test failed.

TestExtensiontestPackage tests a small package in integration/testdata/weather this way, and TestExtensiontestPackageFakeClaude the same package with UseFakeClaude.

When a test needs more than the generated scope (connections, several scopes, a binding with grants, the execution token itself), pass the whole config as extensiontest.Options{Config: ..., Packages: []string{dir}}. Options.Packages are installed under extensions/<name> beside it. With your own config there is no generated agent: write one with extensiontest.ScriptedShell(dir, turns...) and name its Path as a shell binding’s config.argv (with requires.toolbelt cli), where each launch plays the next Turn with the os CLI as above; or with extensiontest.FakeClaude(dir, turns...) and name its Path as a claude-code binding’s binary. Each launch of the fake plays the next Turn: its toolbelt Calls (Operation, Input, RequestID) through the real os mcp server, then a wait for its Barrier file if it has one, then the end of the turn with its State. Turn(n) returns what turn n saw once its calls are made, including its toolbelt endpoint and token while the turn holds at its barrier. Each Work needs a participant of its own. Either one is the test binary itself, run again with an environment variable set, so it costs no extra build. The setup assistant’s daemon tests (TestDaemonSetupSession and TestDaemonSetupChangeSet, in extension-setup) are written this way, because they test the binding and the agent’s token as well as the package.

Running a package while you build it: open-conduit dev

Section titled “Running a package while you build it: open-conduit dev”

Tests show a package works. To watch it work, with a Work you start, the agent calling the operation and the page the package declares, run it against a throwaway daemon:

Terminal window
open-conduit dev ./weather --secret api_key=env:WEATHER_KEY --entry 'weather={"service":"http://127.0.0.1:8080"}'

open-conduit dev [DIR] (default .):

  • builds the package in DIR as extensiontest.Package does and installs it;
  • writes a config of the generated scope (one entry per extension type, a claude-code binding running the author’s own Claude Code, granted the package’s operations plus read_context, wait and declare_outcome, its routes and a manual route), and refuses to start, with each refusal and the generated config, when config check would;
  • starts serve on that config with its state in a new directory under /tmp, never the operator’s config or state, with the web view on a free local port (--web-addr picks one);
  • prints the web view’s sign-in URL, a link to each page the package declares, the work create command that starts a Work in the scope, and where the state, config and daemon log are;
  • checks DIR for changes every half second, leaving out hidden directories, dist, bin and node_modules. On a change it rebuilds the package and restarts the daemon on the same state, so earlier Work and Events stay. A build or config that fails is reported and the running daemon is left as it was;
  • on Ctrl-C (or SIGTERM), stops the daemon, kills anything it started that is still running a few seconds later (an agent’s wrapper outlives its daemon by design), and removes the directory.

Flags:

  • --secret NAME=VALUE adds a secret to the scope and selects it in every generated entry, as extensiontest.Secret does. NAME=env:VAR resolves it from VAR in dev’s environment instead, so the value stays off the command line. Repeat it for each secret.
  • --entry TYPE=JSON is the config of the generated entry for TYPE, as extensiontest.Entry gives it; {} when not given.
  • --agent PATH is the Claude Code CLI the binding runs; by default, claude on PATH. The agent runs with your own Claude Code sign-in (CLAUDE_CONFIG_DIR when set). It has no built-in tools, only the toolbelt.

dev and extensiontest share the build and the scope’s generation (extensiontest/devscope), so a package runs under dev exactly as its tests run it. TestDev drives dev against protocol/examples/notes with a scripted agent: a Work calls notes.lookup, the notes page loads, a change to the source restarts the daemon, and an interrupt leaves no process or directory behind.

open-conduit extension certify DIR [--json] [--report FILE] is the distributable conformance runner. It ships in the open-conduit binary. It starts the package exactly as the daemon does: internal/extproc, with a real SQLite coordination store and artifact store in a temporary directory, and the package’s own fixture. It then runs the criteria and prints or writes a report. The exit status is 0 only when the package is certified. make certify certifies the first-party packages and the examples, and runs the negative controls.

ID Applies Requirement
manifest.valid always Strict manifest and package layout, directory named after the manifest
handshake.manifest always initialize returns the file’s manifest at a common version; configure with the fixture succeeds and registers only own names
handshake.version_refusal always An offer of only unsupported versions is refused with unsupported_protocol_version
lifecycle.before_initialize always A request before initialize is refused
lifecycle.unknown_method always method_not_found, and the connection stays usable
lifecycle.shutdown always shutting_down after shutdown
lifecycle.stdin_eof always Exits within 5 s of stdin closing
lifecycle.restart always Killed, it is restarted and completes the handshake again
transport.clean_stdout always Only protocol messages on stdout
scope.host_surface always No host call refused as unauthorized during the run
storage.kv_migration kv_version > 0 Migrates from layout 0, and again from 0 (idempotent)
ingress.conformance ingress type conformance.CheckIngress on the fixture’s case
routes.decisions routes and cases Each case decided deterministically as expected, with any Work display within its limits and http(s) links
operations.cases operations Each case’s output or refusal code; an undeclared operation is refused
executor.conformance executor conformance.CheckExecutor
executor.inject inject capability conformance.CheckInjectExecutor
executor.controls stop or forget Served for an execution the package does not hold
executor.resource_mode executor with resource_mode Each executor mode case’s validate executor_mode answer is the expected mode, twice identically
resources.workspace resource type with mode_default or sandbox Each resource case’s validate resource_mode names the expected mode and validate resource_sandbox refuses exactly as expected, twice identically
sandboxes.policy sandbox provider type Each sandbox case’s validate sandbox refuses exactly as expected, twice identically, with an accepted and a refused case for every type; sandbox_provider.adopt of a plan the provider never made is refused
containers.policy container provider type Each container case’s validate container refuses exactly as expected, twice identically, with an accepted and a refused case for every type; container_provider.inspect never reports a container the provider never made as running, and container_provider.list for an execution it never saw lists nothing (negative control list-any)
tunnels.policy tunnel provider type Each tunnel case’s validate tunnel refuses exactly as expected, twice identically, with an accepted and a refused case for every type (negative control tunnel-open)
surfaces.served checks or tasks Every check answers; every task is served

A package is certified when every applicable criterion passes. A declared ingress type or executor, an executor declaring resource_mode, a resource type declaring mode_default or sandbox, or a sandbox, container or tunnel type without its fixture cases fails. Missing route and operation cases are noted. tunnel.run has no criterion: the host keeps the listener and what it serves, so a provider can only report where webhooks point, which the operator’s public_url does too, and running one needs its third-party program. A tunnel type’s config check does have one (tunnels.policy), because the host passes the operator’s config on unread.

certification.json holds either an inline fixture or a command. The command prints the fixture as one JSON line and keeps serving (for example a fake service) until stdin closes. {tmp} and {state} in the fixture are replaced with the run’s temporary and state directories. The format is protocol.Fixture:

  • entries, sent in configure, with their scopes, and secrets for the names bound to them;
  • one ingress case: source, delivery, canonical observation, one-shot family and delivery. A source whose families are all re-derivable (reconcile recovers everything) leaves the one-shot family empty;
  • routes, operations (handler operations), one executor case and one inject case with its ready and finish files;
  • resources: a resource type and config, the mode_default it names, and whether resource_sandbox refuses it (sandbox_refused) for an executor requesting mode;
  • executor_modes: an executor, a binding config and the mode it requests;
  • sandboxes: a sandbox type, a config, and whether validate sandbox refuses it (refused).

receive_time_varies declares a package that stamps reconcile observations with its own clock (Slack does). The runner then compares observations without received_at and says so in the result.

A report states:

  • the runner and criteria version;
  • the package name, version and digest (SHA-256 of the manifest and binary);
  • the package’s and host’s protocol versions and the negotiated one;
  • the host build, Go version, OS and architecture;
  • every criterion’s result;
  • the limits: what certification does not establish. It is not a sandbox or a safety review. Only the fixture is exercised, against fakes. One host, platform and protocol version are tested. The surfaces not covered by version 2 are webhooks, setup, providers, telemetry, annotations, rendering, emit and wait operations, the in-transaction callbacks, and the size and overload limits; the host’s own suites cover those adapters.

protocol/examples/weakened packages the examples with one defect each. TestCertificationNegativeControls proves that each fails the criterion it names, and is never certified:

  • version-blind negotiation;
  • manifest drift;
  • stdout noise;
  • no exit on EOF;
  • unstable push identities;
  • non-deterministic routes;
  • wrong operation refusals;
  • a non-idempotent migration;
  • a foreign KV write;
  • acknowledgement before recovery metadata;
  • replacement on recover;
  • inject accepted for unknown executions.
  • TestCertificationFirstPartyPackages: every package open-conduit extension-run serves, and the notes and runner examples, are certified, and their reports state versions and limits.
  • TestCertificationNegativeControls and TestCertificationInvalidPackage.
  • make certify certifies the TypeScript example weather; make ts-check typechecks and unit-tests the TypeScript SDK and the example.
  • TestTypeScriptWireMatchesGo: every field of the TypeScript SDK’s wire messages is a JSON field of the Go type it encodes, and every field Go can send as null is nullable there.
  • TestExternalSlackRoundTrip: the Slack package round trip. Socket envelope through ingress.deliver, acknowledged after acceptance; remote route; slack.reply via sdk.Store and the projector against the fake; projection task; check; render.
  • TestExternalShellAdoptionAndArtifacts: the shell package’s process group survives an extension crash and is re-adopted with the same recovery metadata; its log reaches the host artifact store.
  • TestExternalSurfacesRoundTrip: validate, webhook.handle, provider.* with provider.owner_status, telemetry.export, run.environment, work.annotate, and setup.run with callbacks.
  • TestSDKStoreOverProtocol.
  • TestDaemonExtensionPackages (in make drill): CLI install, conflict refusal, offline config check, a workflow end to end in the real daemon, the validate hook, configure on apply, no orphans after a daemon kill, restart, and removal refused while in use.
  • Unit tests in internal/extproc (install, update, rollback, removal, diagnostics, and extension check in check_test.go).
  • TestExtensionNew: extension new writes a repository whose own make check passes against this checkout, and refuses a directory that isn’t empty.
  • TestExtensionCheckCommand: extension check through the binary, text and --json.
  • Workflows A–D pass on the composed daemon, with every first-party extension running as a package (TestDaemonAllWorkflows).
  1. Packages are directories under <config dir>/extensions/<name>/, read only at daemon start. Install, update, rollback and removal are offline CLI commands that take effect at the next serve; there is no hot load.
  2. Install stages and renames atomically, keeps exactly one previous release for rollback, refuses a lower kv_version, and refuses names the host registers itself. A package installed under a bundled package’s name replaces the bundled one (bundled with the release).
  3. Removal keeps durable state (KV, recorded layout, state directory, log) and is refused while the config uses the package, unless forced.
  4. Compatibility is the protocol version (no host-version range), which changes only for a breaking change, with a policy of keeping the previous protocol version for at least one release after one. Additive surfaces (new methods and manifest fields) ship without a bump; a package that uses one is refused by an older host with needs_newer_host.
  5. The SDK’s final shape: sdk.Extension fields per surface, sdk.Host, sdk.Store (precondition-on-read optimistic commits; prefix ranges not protected) and sdk.Artifacts, plus sdk.PackageCommand for packaging. sdk.New and its registrations (Operation with typed sdk.Handler, sdk.Wait and sdk.Emit, Route, WorkKind, Task, Check, IngressType …) fill the same fields and derive the manifest from them; an operation’s input struct gives its manifest input schema.
  6. secret.resolve takes a connection field and configure entries carry name and connection, so a package reaches its connections’ secrets without bootstrap glue (B05 scoping kept).
  7. Packages start before the configuration opens. A package that fails to start leaves its types unregistered, so a config using them fails validation and the daemon refuses to start with the diagnostic.
  8. Certification criteria version 6 (above; version 2 added the Work display limits to routes.decisions, version 3 executor.resource_mode and resources.workspace, version 4 sandboxes.policy, version 5 containers.policy, version 6 tunnels.policy), run against the package’s own fixture by open-conduit extension certify. The fixture format lives in protocol. A declared ingress type or executor requires a fixture case.
  9. receive_time_varies relaxes only received_at equality for packages that stamp reconcile observations with their own clock.
  10. The certification runner is production code in the open-conduit binary, so composition may depend on the public conformance checks (never conformance/reference); the boundary rule says so.
  11. The Slack package requires source namespaces slack or slack/… (the subprocess namespacing rule).
  12. Playbooks of installed packages are wired into agent executors as first-party ones are: the host writes every package’s playbooks into the turn plugin.
  • 07-03. Built on the protocol (07-01) and its runtime (07-02). It wired installed packages into the daemon, adapted the surfaces 07-02 had left catalogued (webhook.handle, provider.* with provider.owner_status, telemetry.export, work.annotate, validate, setup.run with the setup.* callbacks), settled the SDK’s final shape, added the package layout, the open-conduit extension commands, diagnostics and certification, and packaged the Slack extension and the shell executor as the round trip of two first-party integrations. It added the connection field to secret.resolve and the name and connection fields to configure entries, and narrowed the package boundary rule so composition may depend on the public conformance checks. It recorded decisions 21–32 and the evidence listed above.
  • 07-03 listed two items as “not wired, and recorded as follow-ons”: playbooks of installed packages were not copied into the Claude Code plugin directory, and installed packages’ work_kind.trigger, recurrence.next and dependency.filter ran through 07-02’s bounded in-transaction calls with the same fallbacks.