Help us shape the future of Go! Take the 2026 Go Developer Survey

open-agent-protocol

module
v0.1.0-alpha.27 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: CC0-1.0

README

Open Agent Protocol

Draft public-domain semantic protocol for agent software layers, agent loops, model IO, tools, resources, and adapters for existing agent SDKs.

The first conformance target is an agent-control core covering the boundary between a control layer and an agent loop: capabilities, session state, message submission, run lifecycle, streamed content, cancellation, and state recovery without binding to a specific transport.

OAP layers are logical software boundaries, not deployment sides. A presentation layer, control layer, agent loop, model provider, tool executor, resource provider, and binding can live in one process or across multiple transports while preserving the same protocol semantics. Adapters for existing SDKs or protocols are implementation shims, not a separate semantic layer.

drafts/composition.md sets out how the layers compose and what a client may choose about the layers below the one it talks to. Two of those boundaries have profiles. agent-control-core is the control layer talking to an agent loop — sessions, runs, tools — and is what this repository executes today. Decision 0016 and drafts/model-provider-core.md specify the lower model-provider boundary. oapx now implements both profiles and uses model-provider-core for its own agent inference; it can expose them together on one stdio connection. Other agent implementations may still wrap vendor SDKs internally.

Quick start

oapx is a single binary. Get one of these two ways:

  • Download it from the latest release. Builds are published for macOS, Linux and Windows on arm64 and x64, with a SHA256SUMS.txt.
  • Or build it from this checkout with Zig 0.17.0. make install puts it in ~/.local/bin.
oapx auth providers                      # the providers you can sign in to
oapx auth login --provider <id>          # store a credential (Keychain first on macOS)
oapx                                     # the terminal UI, over any stored credential
KIMI_API_KEY=<key> oapx run --agent "summarize this repo"   # one prompt over Kimi, every event printed
oapx serve agent,provider --stdio        # the endpoint the SDKs drive

How oapx works

oapx sits between whatever drives an agent and whatever runs it. Every client speaks the same OAP envelopes to it. Behind it there are two kinds of backend:

  • its own agent loop, which reaches any catalogued model provider;
  • a third-party harness, translated by a pinned adapter.
flowchart TB
    subgraph clients["Clients"]
        direction LR
        tui["Terminal UI<br/><code>oapx</code>"]
        sdk["SDKs<br/>Python · Rust · TypeScript · Go"]
        remote["Any OAP client<br/>stdio or HTTP + SSE"]
    end

    subgraph oapx["oapx — one binary"]
        direction TB
        hub["<b>serve</b><br/>many sessions · replay"]
        agent["<b>serve agent</b><br/>agent-control-core"]
        loop["Built-in agent loop<br/>tools · permissions · compaction"]
        adapters["Harness adapters<br/>pinned per version"]
        provider["<b>serve provider</b><br/>model-provider-core"]
    end

    harnesses["Harnesses<br/>Claude Code · Codex · Pi<br/>ACP agents · Hermes<br/>DeepSeek · OpenCode"]
    models[("Model providers<br/>24 catalogued: Anthropic,<br/>OpenAI, Google, Kimi, Z.AI, …")]

    tui --> agent
    sdk --> agent
    remote --> hub
    hub --> agent
    agent --> loop
    agent --> adapters
    loop -- "inference.*" --> provider
    provider --> models
    adapters --> harnesses

    classDef client fill:#e8f1ff,stroke:#3b6fd8,color:#0b1f44
    classDef core fill:#fff4e0,stroke:#d08a00,color:#3d2600
    classDef edge fill:#eaf7ee,stroke:#2f9e57,color:#0d3319
    class tui,sdk,remote client
    class agent,hub,loop,adapters,provider core
    class harnesses,models edge

The two profiles share one envelope and vocabulary:

  • agent-control-core: sessions, runs, streamed content, tool calls, permission prompts and cancellation.
  • model-provider-core: one inference request, and its parts as they stream.

They can be served apart (serve agent, serve provider) or together on one connection (serve agent,provider). The built-in loop is itself a client of the provider profile, so a remote provider service can stand in for the local one.

One run, end to end

A session opens once. Each submitted message is admitted as a run, and the run ends with exactly one terminal event. A tool call that needs approval pauses the run until the client answers. Inference happens underneath, on the provider profile.

sequenceDiagram
    autonumber
    participant C as Client (TUI / SDK)
    participant A as oapx · agent loop
    participant P as oapx · provider
    participant M as Model API

    C->>A: protocol.initialize.request
    A-->>C: protocol.initialize.response (capabilities)
    C->>A: session.open.request
    A-->>C: session.open.response
    C->>A: session.message.submit.request
    A-->>C: submit.response (started · start · running)
    A-->>C: run.started

    A->>P: inference.create.request
    P->>M: provider HTTP request
    M-->>P: streamed tokens
    P-->>A: inference.part.delta …
    A-->>C: content.delta …
    P-->>A: inference.completed (tool call)

    A-->>C: action.permission.requested
    C->>A: action.permission.resolve.request (allow)
    A-->>C: action.permission.resolved
    A-->>C: action.call.started → action.call.completed

    A->>P: inference.create.request (with the tool result)
    P-->>A: inference.part.delta … inference.completed
    A-->>C: content.delta …
    A-->>C: run.completed (final response, usage)

A run can be cancelled (run.cancel.request → run.cancelled) at any point. While one run executes, follow-ups queue behind it. A client that reconnects can replay the session's events from a cursor, or is told exactly where the gap is.

Inside the built-in loop
flowchart TB
    subgraph host["Host: TUI, serve agent, or oapx run"]
        session["Session<br/>history · settings · compaction policy"]
    end

    subgraph agentlayer["Agent layer"]
        loopn["Agent loop<br/>turns · queue · cancellation"]
        tools["Workspace tools<br/>Read · Write · Edit · Shell · MCP"]
        perms["Permission engine<br/>ask or bypass"]
    end

    subgraph providerlayer["Provider layer"]
        mpc["model-provider-core"]
        catalog["Model catalog<br/>providers/catalog.json + models.dev"]
        creds["Credentials<br/>macOS Keychain, else ~/.oapx/auth.json"]
    end

    apis[("Provider APIs")]

    session --> loopn
    loopn --> tools
    tools --> perms
    perms -. "action.permission.requested" .-> host
    loopn --> mpc
    mpc --> catalog
    mpc --> creds
    mpc --> apis

    classDef h fill:#e8f1ff,stroke:#3b6fd8,color:#0b1f44
    classDef a fill:#fff4e0,stroke:#d08a00,color:#3d2600
    classDef p fill:#f3e8ff,stroke:#8a4fd0,color:#2a0d4d
    classDef x fill:#eaf7ee,stroke:#2f9e57,color:#0d3319
    class session h
    class loopn,tools,perms a
    class mpc,catalog,creds p
    class apis x

Each provider's base URL and sign-in origin live in exactly one place, providers/catalog.json. The model list comes from that catalog plus models.dev, which supplies context limits and whether a model is deprecated. State lives under ~/.oapx.

Harness backends

oapx serve agent --backend <name> swaps the built-in loop for a third-party harness. The client sees the same envelopes either way. Each adapter is pinned to the harness versions its catalog entry in harnesses/ names. Its research/ ledger records everything the harness cannot express in OAP, and the client is told about those gaps rather than having them hidden. Choosing a backend says what each one needs to start and how it authenticates.

Backend Runs Pinned
oapx oapx's own loop, as the terminal UI builds it —
claude a Claude Code child 2.1.302
codex a Codex app-server child rust-v0.163.0
pi a Pi RPC child v1.3.0
acp an ACP v1 agent named by a --config entry v1.12.0
hermes a Hermes gateway v0.22.2
deepseek a DeepSeek harness dsh-v0.2.1-rc.2
opencode an OpenCode server, started by oapx or already running v2.0.32
memory the in-memory reference script that conformance runs against —

When opencode names no endpoint, oapx runs the whole server lifecycle itself. The terminal UI uses the same route when you pick an OpenCode model in /model:

sequenceDiagram
    participant O as oapx
    participant S as opencode serve
    participant C as Client

    O->>O: pick a binary: the entry's executable, else the installed build, else PATH
    O->>S: spawn on a free loopback port with a fresh password
    loop until ready (503 means still starting)
        O->>S: GET /api/info
    end
    alt version not admitted by the catalog
        O-->>C: refuse, naming the tested version and oapx opencode install
    else admitted
        C->>O: session.open / session.message.submit
        O->>S: create session, prompt
        S-->>O: native events
        O-->>C: run.started · content.delta · run.completed
    end
    O->>S: SIGTERM, then kill after the grace period

To run the terminal UI over OpenCode:

oapx opencode install
oapx tui --backend opencode

oapx opencode install downloads the OpenCode build the catalog pins for this platform (v2.0.32), checks its sha256, and unpacks it under ~/.oapx/opencode/<version>. oapx tui --backend opencode then starts that server, runs the terminal UI over it, and stops it on exit. With --config <path> it takes the registry entry named opencode instead, and an entry naming an endpoint attaches to a server that is already running.

The combined endpoint

Run the combined endpoint with:

oapx serve agent,provider --stdio

The three SDKs in sdk/ — Python, Rust and TypeScript — and the Go one at go/sdk/ use this OAP mode by default. The agent's selected model can be changed mid-session with session.model.switch, and local authentication flows use the optional +auth unit. Dynamic provider attachment is specified but optional; a remote HTTP provider binding is specified in the HTTP binding. oapx can use an operator-configured remote provider for agent inference by setting OAPX_PROVIDER_SERVICE_URL and OAPX_PROVIDER_SERVICE_SECURITY to loopback, tls, or mesh_proxy; the remote service must expose POST /oap/v0.1/provider. An oapx provider can serve that endpoint with oapx serve provider --http 127.0.0.1:8080; a sidecar or reverse proxy must provide authenticated cross-Pod exposure. Use the remote-service settings with oapx serve agent --stdio; the combined agent,provider role refuses them rather than exposing a different, local provider profile. Live session.provider.attach is not yet implemented. The oapx adapter serves client-provided tools (action.tools.provide, Decision 0011) and takes an agent run's reasoning level and output limit at open, so the SDKs provide a request's tools and answer the calls routed to them wherever the endpoint advertises the feature, refuse them client-side where it does not, and refuse an agent run's temperature, which the loop takes none of, and minimal reasoning, which it runs as low. The old Makai wire is available only by explicit opt-in.

Backends in detail

oapx serve agent --backend claude serves a Claude Code child (pinned 2.1.302), --backend codex a Codex app-server child (pinned rust-v0.163.0), --backend pi a Pi RPC child (pinned v1.3.0), an acp entry an ACP v1 agent (pinned v1.12.0), a hermes entry a Hermes gateway (pinned v0.22.2), a deepseek entry a DeepSeek harness (pinned dsh-v0.2.1-rc.2), and an opencode entry an OpenCode server (pinned v2.0.32), behind the same stdio door instead of the built-in loop, following the endpoint binding. Without --config it runs claude from PATH with only HOME and PATH in its environment and the harness-default tool posture, so every gated tool call still reaches the host as a user.input gate. --config <path> takes the entry of that name from a registry shaped like examples/oap-serve.json: unknown members are refused and environment is an explicit allowlist. --backend opencode with no endpoint starts its own opencode serve (the entry's executable, else the one oapx opencode install put under ~/.oapx/opencode/<version>, else opencode from PATH) on a loopback port under a fresh password, logs it to ~/.oapx/opencode/<entry>.log, refuses a server whose /api/info reports a version the catalog does not admit, and stops it on exit; an entry naming an endpoint attaches to a server already running instead. oapx opencode install downloads the OpenCode build the catalog pins for this platform from https://opencode.ai/files/bin/<version>/, refuses an archive or binary whose sha256 is not the pinned one, and unpacks it under ~/.oapx/opencode/<version>. --backend oapx serves oapx's own agent loop as the terminal UI builds it — the same model catalog, workspace tools and permission engine — through the adapter in zig/src/adapter/oapx/, so a session gets every catalogued provider and the loop's tools, and cancelling a run leaves the session open; it passes goap conformance against a live model. It is the adapter the terminal UI moves onto (#375). The host selects ask or bypass mode. In ask mode, tool permissions reach the client as action.permission.requested and wait for one matching response from the declared responder. The built-in request_user_input tool asks text, single choice or multi choice questions through user.input.requested; every answer is validated before reaching the native tool. Permission approvals apply to one call; argument rewrites and persistent approval choices are refused. Cancelling a run closes its pending prompts and leaves the session usable. Queue delivery reserves up to eight waiting runs behind one executing run and promotes them in admission order. Busy auto joins that queue; explicit queue reserves even when idle. session.state.active_runs lists the reservations with their queue positions, so clients can count follow-ups and cancel them before execution. Cancelling a reservation frees its slot within that request; the acknowledgement precedes its terminal event, and a buffered next request sees the released slot. The descriptor discloses both bounds. Replay retains up to 65536 events across the session's runs and reports gaps after eviction. A compaction request (Decision 0044) is a run of its own, admitted like a submit, in which the loop summarizes the history with the session's model; the session's compaction policy sets the threshold at which the loop compacts between the turns of a run. --backend memory serves the in-memory reference script and answers exactly as goap's does: CI runs goap conformance against it and a parity job that feeds both trees the same traffic and requires identical output. What that job is the last check on, and what the corpora cover instead, is written down in docs/parity-job.md. What each cannot do through this path is recorded in its ledger: claude, codex, acp. Without --config, codex runs codex from PATH with only HOME and PATH; an ACP agent has no default and needs a --config entry naming its executable.

Claude's session.open.reopen reloads the bound CLI UUID through --resume, then reads get_settings for the actual model, effort and compaction policy. Recovery discloses that configuration belongs to the new process; only the conversation is restored. Missing native sessions return a typed unsupported_feature refusal; earlier OAP run events remain unavailable.

Specifications

Implementing OAP natively? STABILITY.md describes the v0.1 pre-release contract: what will freeze at the first tag, what may still be added, how conformance is defined and answered, how long a deprecation runs, and what you are owed before a breaking change lands. Implementers who want notice of proposed breaking changes add themselves to IMPLEMENTERS.md.

Current drafts:

Protocol artifacts:

  • Illustrative protocol envelopes
  • The system map — what the pieces are and where the port stands, as one page. Open it in a browser; GitHub serves an .html file as source.
  • fixtures/: normative executable conformance traces
  • fixtures/packs/: extension packs, loadable with go run ./go/cmd/goap validate -pack
  • schema/v0.1/: JSON Schema bundle for agent control and model provider profiles

Executable core (Go 1.26 or later, the go.mod floor; CI runs 1.27.x):

go run ./go/cmd/goap check

The command validates positive and negative fixtures and drives the deterministic in-memory reference adapter. The reference adapter proves the public adapter boundary and bounded process-memory recovery; it is not a production harness or a durable persistence implementation.

Validating a trace (goap validate)
go run ./go/cmd/goap validate [--format=human|json] [-mode strict|tolerant] [-pack <dir>]... <trace.json>...

-mode strict (the default) compiles the bundle exactly as published: an unknown member, enum value, or envelope type fails. -mode tolerant compiles it under the layered draft's extension rules, so a later revision's additive field or an extension's envelope type is accepted on the common fields alone.

-pack loads an extension pack — a directory holding a pack.json descriptor and the schemas it contributes — and is repeatable. With the pack loaded its envelope types are validated against its own branches and its capability keys are gated exactly as core keys are; without it the same envelopes take the tolerant unknown path. Both flags are the caller's stated choice and neither is inferred from the input: a live envelope saved to a file is indistinguishable from a fixture. fixtures/packs/storage is a worked pack, and Decision 0004 specifies the format.

Run controls (+run-controls)

A submission may carry four per-run controls — model_id, instructions, tool_choice, and output_schema — and each is gated on its own capability key. The discipline is fail-closed: a control an endpoint has not affirmatively advertised is refused before admission with unsupported_feature naming the key, a degraded one needs the caller's allow_degraded_features opt-in, and one whose value cannot be honoured is refused as unsatisfiable. Nothing is accepted and ignored, and a refusal always says what to change. A request failing more than one of these gets one refusal, ranked capability → degradation → unsatisfiability → state.

Execution is claimed per control, only for the keys an endpoint advertises above unavailable, so an endpoint that supports none of the four still claims the unit by refusing all four correctly. Decision 0005 graduates the discipline and model_id: Codex applies it natively per run (run.model_selection, scope run), which means the model binds that run alone and current_model_id — the model the next control-free submission would use — does not move. The reference adapter executes all four.

Queue delivery (+queue)

A session may hold one started run and an advertised number of queued reservations. An explicit delivery: "queue" is admitted as a reservation — admission: "queued", nothing emitted for it yet — and promotes when every earlier-admitted run of the session is terminal; on a busy session an auto submission resolves to the same shape and says so with delivery_resolution: "session_busy". One run executes at a time, in admission order, with one exception: the pre-start terminal of a reservation that never started is published when it happens, because the slot it releases is capacity the trace has to show at the moment it changes.

Advertising the queue is a claim that some submission will be queued, so the descriptor discloses the bound that makes the claim checkable: capabilities.response.limits carries max_queued_runs_per_session and, optionally, max_active_runs_per_session. A queue advertised with no reachable bound is a defect, not a permissive default. Beyond the bound the wire's answer is run_active, and that refusal is validated in both directions: a reached bound may not hide behind another code, and a bound the window shows was never reached may not be reported at all.

session.state grows active_runs — every nonterminal run in admission order, with the reservation's queue position — and as_of, the position the snapshot was captured at, so a state read that raced a lifecycle event is reconciled rather than diagnosed. Decision 0007 graduates the unit; OpenCode advertises it native on SessionInput.Admitted, and the reference adapter emulates one reservation.

Tool sources (+tool-sources)

A tool catalog says where each of its tools comes from. action.tools.list.response carries sources — { id, kind, display_name?, protocol?, endpoint? } — and each ToolDefinition carries the source id it belongs to, so a consumer attributes a call to an MCP server without parsing a namespaced name, and action.call.* carries exactly what the catalog in force attributes the tool to — the session's own catalog where it has served one, the descriptor's otherwise — and nothing where neither does, because a cross-reference a reader cannot follow is not one. An endpoint whose sources are known before any session declares them in its descriptor and may name them from the start; one that learns a source from a session names it once it has served the catalog that publishes it, and before that the catalog keeps the attribution exactly. A source id is unique across a session's catalog and a tool name is unique whatever its source. The catalog is gated on action.tools.list and nothing else: action.tools means lifecycle observation, and several adapters observe tool calls while publishing no portable catalog at all.

A session may attach sources for its lifetime: session.open.request.tool_sources carries ToolSourceAttachment values — the descriptor's members plus, for a process source, command, args, and environment. The attachment shape is deliberately not the catalog shape, because an attachment's environment can hold a credential: a published source carries none of those three members, and an endpoint that reflected one back into its catalog is a schema rejection rather than a convention. The endpoint discloses what it accepts in action.tool_sources.attach — modes (always session_open, plus remote where offered) and limits (max_sources, the transports it takes) — so a refusal is checkable in both directions. The modes are a set, following run.tool_selection, so disclosing remote never erases the session-open one.

The daemon does not take a command from the wire. "Loopback, single-user" describes the transport, not the origin of a request on it: a page in the user's browser can reach 127.0.0.1 with a valid envelope. On POST /adapters/{name}/sessions a process attachment therefore names an operator-configured source by id only — from the registry document's tool_sources map — and the daemon fills the rest from its own entry: the command, args, and environment it runs the source with, and the kind, display_name, protocol, and endpoint it publishes for it. A wire-supplied command, argument list, or literal NAME=value environment value is refused before the open is forwarded, and so is a kind, display_name, protocol, or endpoint differing from the operator's — a caller that could label the operator's own MCP server in the catalog a user reads would be spoofing it, one that could set its kind would choose how it is reached, and overwriting either silently would leave the request and its response disagreeing about one source. The bare-NAME allowlist form is the only environment a wire caller may write, and it is additive only for names the operator did not configure: a caller naming one the registry entry already carries is dropped, so the operator's value is the one the source runs with and one variable never reaches a child twice. A registry entry naming one variable twice is refused at registration for the same reason. An attaching open may pin the descriptor it elected against, and the pin is honoured as the profile states it: a capability_revision that is not the endpoint's current one is refused stale_capabilities with expected_revision and current_revision, while an open that carries none is evaluated against current capabilities and answered with the revision it was admitted under. Both clients pin — one extra request, on attaching opens only — because the validator is stricter than the wire here and requires any optional-feature envelope to cite the active descriptor. An open that attaches nothing is not probed and keeps its own revision. A configured id resolves to the operator's source whatever the caller claims about it, so an open that names an id alone gets a fuller descriptor back than it sent, and never a different one. Beside that, every route refuses a request bearing an Origin header, and the routes that read a request body also require Content-Type: application/json. The origin refusal wraps the whole mux rather than living in the routes that parse an envelope, so it covers the ones that read no body — close — and the ones not yet written.

Every served catalog carries the capability_revision it was served under, and capability_revision is schema-required on action.tools.list.response as it is on models.response: the catalog belongs to one descriptor snapshot, so a caller caches it against that revision and discards it when capabilities.updated reports another. It matters more here than on the models route, because a session's catalog is a function of the descriptor and of the sources that session attached under it. The hub checks what an adapter hands back before it reaches a codec — the payload's scope must be the scope the request named, in both directions, and the revision must be present — because a codec labels the envelope with the session it addressed, so an unchecked payload scope would survive into a response the clients reject. A mis-scoped catalog is refused rather than relabelled; a nil tool list is repaired, because an absent list and an empty one say the same thing and only one is legal on the wire.

Decision 0008 graduates the unit: Claude Code serves the catalog at degraded from its per-turn system/init frame, and ACP attaches at native onto session/new's mcpServers. Control-layer-provided tools are the separate +control-tools unit and are not graduated by it.

Models catalog (+models)

A session publishes the models it can run, and is bound by the listing in both directions: an id the catalog omits is refused model_not_found naming it, and an id it lists is never reported missing. A catalog that accepts an unlisted model, answers model_not_found for a listed one, or refuses an unlisted one under a code the caller cannot act on is diagnosed the same way — model_not_in_catalog — because each leaves a picker built on the listing unable to trust it. A listed selection may still be refused for reasons that are not about the model at all — a busy session, another control — and those refusals are left alone.

The catalog belongs to the capability revision it was served under: a refresh discards it, and within one revision it may not move without a capabilities.updated. GET /sessions/{id}/models serves it over HTTP, the models op over stdio, Session.Models and session.models() from the two clients; the degraded opt-in travels with the query on every one of them. Decision 0006 graduates the unit. OpenCode advertises models.list degraded and serves the models a session is observed to run — its server's own catalog routes have no pinned response shape at the adapter's pin — which makes it the first native adapter to exercise the degraded opt-in end to end. The reference adapter serves the fixed catalog its model gate already enforces.

Local daemon (oapx serve)

oapx serve is the product's multi-session daemon; hub is its old name and still works. It carries the registry config (--config), the stdio transport-object wire (--stdio) and HTTP + Server-Sent Events (--addr). goap serve is the same daemon in this repository's Go tree, and it is not installed: run it with go run. Decision 0038 is why there is one released binary, and a library for every language.

goap serve exposes the adapter registry over HTTP + Server-Sent Events so any client — not only Go hosts — can drive any OAP adapter. The daemon is a thin HTTP+SSE codec (serve/servehttp) over the embeddable serve package; its wire behavior is the contract the client package proves:

go/serve is the library, and it is for in-process hosts. A Go host that wants the hub driven inside its own test binary imports go/serve and calls it directly — no socket, no port, no second process. oapx serve is the product's daemon, and the two are held to one wire by CI rather than by assumption: go/cmd/goap's differential drives both over stdio and compares every line, and clients/ts's suite runs against each over HTTP + SSE.

go run ./go/cmd/goap serve [--config examples/oap-serve.json] [--addr 127.0.0.1:6270]

Without --config the daemon serves the built-in memory reference adapter only. The registry document maps names to in-repo adapter configurations (examples/oap-serve.json shows every entry type); constructor requirements (absolute working directories, provider settings) surface at startup. The environment list of an entry is an explicit allowlist: a bare NAME forwards the value the daemon itself carries (unset names are omitted) and NAME=value passes through literally — ambient credentials are never inherited by a child process unless their variable was listed.

The document's tool_sources map is the same allowlist idea for the MCP sources a client may attach at session open: each entry names a kind, the descriptor members the catalog publishes, and the command, args, and environment the daemon supplies on the client's behalf. Its environment takes the same form with one rule of its own: a bare NAME the daemon does not carry fails at startup, naming the source and the variable, rather than being omitted the way an adapter's is. An adapter's list forwards whatever of a harness's variables the daemon happens to have; a tool source's names the credentials of one executable the daemon itself launches, and dropping one starts that MCP server without its token to fail later as though the server were broken. Write NAME= if a name is meant to be optional. (The example config lists MCP_TOKEN for its filesystem source, so export it or drop the entry before starting with that document.) A process entry must carry a command: it is the one kind the daemon supplies an executable for, so an entry without one could never resolve, and the failure is reported when the entry is registered — naming the source — rather than as a generic open_failed on the first open that attaches it. Other kinds need none, because nothing spawns them. A kind outside the protocol's five is refused too, because no adapter can accept it and no client can name it. Both rules belong to Registry.RegisterToolSource, which the config loader goes through, so an embedding host registering entries programmatically meets the same checks rather than weaker ones. A client attaches one by id and nothing else — see "Tool sources" above for why the wire form is refused on that route.

The daemon binds 127.0.0.1 by default and has no authentication: v0 is a single-user local service, and pointing it at an external interface is explicitly unsupported. On a loopback bind the daemon serves only requests whose Host header names a loopback host, which closes the browser-borne cross-origin and DNS-rebinding vectors against an unauthenticated local service; binding a non-loopback --addr deliberately opts out of the single-user trust model. Restarts kill every session — run child processes are per-session and no adapter here survives a daemon restart — and no session state persists across restarts. Session entries accumulate for the daemon's lifetime (closed sessions stay listed with their final state); there is no eviction in v0.

OAP operations exchange verbatim schema/v0.1 envelopes (rejected input gets a correlated error.response; GET /capabilities responses cite a daemon-minted correlation id a client can pair with its own request envelope):

Endpoint Operation
POST /adapters/{name}/sessions session.open.request → session.open.response
GET /adapters/{name}/capabilities capabilities.response (probed descriptor)
POST /sessions/{id}/submit session.message.submit.request → admission response
GET /sessions/{id}/events SSE stream of run-event envelopes
POST /sessions/{id}/resolve action.permission.resolve.request or user.input.resolve.request → response
POST /sessions/{id}/cancel run.cancel.request → run.cancel.response
GET /sessions/{id}/state session.state.response
GET /sessions/{id}/models models.response (repeatable ?allow_degraded=<key>)
POST /sessions/{id}/close Close (v0.1 defines no close envelope; returns 204)
GET /adapters, GET /sessions daemon-management listings, plain JSON
GET /sessions/history the session history, live and closed, paged (?limit=, ?cursor=); plain JSON

The daemon acts as participant user: interactive gates opened over a session resolve with responded_by: "user".

GET /sessions/{id}/events streams envelopes with data: carrying the envelope JSON and id: carrying the envelope sequence, so an SSE Last-Event-ID reconnect (or an explicit ?after= cursor) maps directly onto Resume.AfterSequence for the session's current run: the daemon drives the adapter Resume and streams the replayed suffix, then live events, and ends the stream at the run's terminal event. Two terminal signals are transport framing, not envelopes: event: oap-overflow reports that the connection's bounded buffer fell behind (last_sequence names the last sequence this connection delivered; reconnect with a cursor after it), and event: oap-replay-gap reports adapter.ReplayGap — the requested cursor is no longer retained (oldest_available/latest_available bound what is; reconnect with a cursor at or after oldest_available - 1). A stream also ends when the client closes the connection; a stream that is open when the session closes receives the events already in flight and then ends, and a connection made to an already-closed session is refused with 409 session_closed rather than parking. One non-terminal signal opens the stream rather than ending it: event: oap-subscribed is written first when the subscription joins a session whose run has already emitted, and joined_after names the last sequence it missed, so a host that subscribed separately can see it did not start at the beginning and resubscribe with a cursor. The signal reports a position inside the run the subscription is attached to, and nothing about earlier runs: a subscription that begins at the start of its run gets no such line, whether that is a compound open's, whose subscription begins before the session has a run, or one that arrives between runs, after a second run is admitted and before it emits. In that last case the subscriber did miss the run before, and is not told so here — those events are another run's sequence space, and a signal naming a run the stream is not carrying would be worse than silence. Sequence numbers are per-run: a connection that happens to span an immediate resubmit (a second run admitted inside the settle window of the first) continues into the new run, and clients keying on the envelope run_id see each run's own sequence space. Because of that, a cursor without a run is ambiguous, and run_id says which one it was cut from: ?after=7&run_id=run-1 replays run-1 whether or not it is still the current run, and ?after=7 alone resolves onto whichever run is current, which is right until a second one is admitted. A run_id the session never had is 404 run_not_found; one sent without a cursor is 400 invalid_cursor, because a live subscription is always the current run.

On SIGINT/SIGTERM the daemon stops accepting, terminates in-flight streams, and closes every session inside a bounded window — active runs that refuse Close are cancelled first — so child agent processes are settled rather than orphaned.

Subprocess embedding (goap serve --stdio)

A host that would rather spawn a child process than manage a port gets the same surface over newline-delimited JSON on the process's own pipes:

go run ./go/cmd/goap serve --stdio [--config examples/oap-serve.json]

Spawning the process is the authorization, so there is no port, no TLS and no Host allowlist. The registry's environment allowlist still governs adapter credentials exactly as it does over HTTP, and a wire-supplied command, args or literal NAME=value in a tool_sources attachment is refused here too — a stdio peer is a separate process on the far side of a pipe, not an in-process embedding of serve.Hub, so it gets the wire rule. --stdio and --addr name two transports and are mutually exclusive.

Stdout carries protocol lines and nothing else. The banner, the adapter list and every diagnostic go to stderr, so a host may parse stdout strictly.

Host → daemon lines are one JSON object each: {"id":N,"op":"...", ...}, where id correlates the answer. It is a signed 64-bit integer — the host picks the value, and negatives are fine, but a fractional number or one outside the int64 range is a framing defect, not a bad request. So is an unknown field, a repeated or case-aliased key, a missing id, a line over the frame limit, or trailing data after the object. The daemon fails closed on all of them; a JavaScript host minting ids above 2^53 should keep them inside int64 and send them as integers.

op params answers with
adapters — the registry listing
capabilities adapter capabilities.response
open adapter, request session.open.response
sessions — the session listing
state session_id session.state.response
models session_id, allow_degraded_features models.response
tools session_id, allow_degraded_features action.tools.list.response
submit session_id, request session.message.submit.response
resolve session_id, request the matching resolve response
cancel session_id, request run.cancel.response
close session_id null
events session_id, after, run_id null, then the stream (below)

request carries a verbatim schema/v0.1 request envelope — the same document the corresponding HTTP route takes as its body, validated against the same bundled schema. An op that takes a parameter it does not define is refused invalid_request: the shape is checked on presence, so a supplied-but-null parameter still counts as supplied.

Daemon → host lines are either responses, {"id":N,"ok":true,"result":...} or {"id":N,"ok":false,"error":{"code":...,"message":...}}, carrying the codes the HTTP routes answer with, or subscription lines, which carry "event" instead of "ok". Exactly one writer goroutine emits them, so every line is atomic and no response is ever broken by interleaving. Four refusals are this framing's own and have no HTTP counterpart: the line-shape pair invalid_request and unknown_op; response_too_large for a result the frame limit cannot carry; and busy.

busy is the one a host has to handle rather than fix. Ops are admitted against a bound on how many run at once and how many request bytes they hold, and a host that pipelines past it gets a correlated refusal instead of a stalled pipe — the daemon answers rather than waiting, because waiting is what would stop it reading the host's end at all. The message ends with send this request again, and that is the whole recovery: the request was never served, so resending it is safe. A host that pipelines deeply should be ready to resend.

events acknowledges with null — the acknowledgement the SSE route gives by starting a bodyless response — and the subscription's envelopes follow as their own lines, tagged with the events request's id so overlapping subscriptions on one session stay attributable. The acknowledgement is always written before the first envelope.

line means
"event":"oap-subscribed" the subscription joined its run already in progress; joined_after is the last sequence of that run it missed. Advisory, so a form too large to frame is retried without session_id, run_id and message and then dropped, never ending the subscription
"event":"envelope" one event, with sequence repeated outside the envelope so a host can resume without decoding it
"event":"oap-overflow" the consumer fell behind; last_sequence is where a cursor resumes
"event":"oap-replay-gap" the after cursor is no longer retained; oldest_available/latest_available bound what is
"event":"oap-session-closed" the session closed under the subscription
"event":"oap-frame-limit" an envelope this framing cannot carry; sequence is where a fresh cursor resumes past it
"event":"oap-stream-failed" the run's event stream failed; run_id and sequence are the last position delivered

Every ending a host could not otherwise observe gets one of these, because this framing has no end to observe: the SSE response body stops and the client sees a closed connection, while the pipe here stays open and carries every other subscription. Two endings need no line. A healthy stream ends at the run's terminal envelope — run.completed, run.failed or run.cancelled — which is delivered as an ordinary envelope line and is the marker, exactly as the SSE response simply ends after it; and the daemon's own shutdown ends the session the host is watching, which the host observes directly.

Ops are pipelined and each runs on its own worker, so responses to overlapping requests may arrive in any order and the id, not the position, is what correlates them. A host that needs one op to precede another waits for its response, exactly as it would over HTTP: a submit sent before its open is answered is refused unknown_session rather than silently queued. drafts/compound-open.md proposes removing that constraint for the common case.

examples/oap-stdio-session.ndjson is the host's half of a full session — listings, open, subscription, an interactive run with both gates answered, cursor replay, close. It is a script rather than something to cat into the process, for the reason just given: each line follows the previous answer, and the two resolve lines follow the gates they answer. TestStdioExampleSessionRuns drives it against the built binary, so it cannot drift from the surface it documents.

Shutdown is stdin EOF or SIGINT/SIGTERM: the daemon stops admitting, settles the work it already admitted inside a bounded window, closes every session so child agent processes are not orphaned, and exits zero. The session sweep runs on every exit, including the failures below, so a child agent process is never orphaned by one.

A non-zero exit means the session did not end cleanly, and stderr carries one bounded diagnostic saying which:

exit reason means
a malformed line the host's framing defect, naming the line number
the output could not be written the host closed its end of stdout, or the write failed
requests were dropped unserved the host ended the session while work it had sent was still unadmitted or unanswered — those requests were never served and may be sent again
shutdown stalled a stage outlived its bounded window and was abandoned rather than waited on

A host that classifies every non-zero exit as bad input will misdiagnose its own closed pipe, or an adapter that outlived the shutdown window, as a protocol error.

Embedding the registry (serve)

The serve package is the transport-neutral core the daemon is built on: a Go host that wants the full registry semantics in process — several adapters, many concurrent sessions, per-session subscriptions with cursor replay — embeds it directly with no HTTP hop. serve/servehttp (the daemon) is one codec over this same hub.

registry, _ := serve.DefaultRegistry()           // or serve.LoadRegistry(path, os.LookupEnv)
hub := serve.New(registry, serve.Options{})

session, _, _ := hub.Open(ctx, "memory", adapter.OpenRequest{SessionID: "demo"})
sub, _ := hub.Subscribe(ctx, session.ID())        // subscribe before submitting
session.Submit(ctx, protocol.MessageSubmitRequest{
	SessionID: session.ID(), Delivery: protocol.DeliveryAuto,
	Messages: []protocol.Message{{Role: protocol.RoleUser, Content: protocol.TextContent("hi")}},
})
hub.Published(session.ID(), "my-request-id") // no-op unless that submit steered a run
for {
	envelope, err := sub.Next()
	if err == io.EOF {
		break // the run reached its terminal event
	}
	if err != nil {
		break // fell behind: resume per the error's cursor
	}
	// resolve gates and collect content as events arrive
}

A submit that steered a run holds the target run's stream until hub.Published(session.ID(), requestID), keyed to the submit request that armed the hold so a concurrent submit's call cannot release it: the hub withholds the envelopes the adapter emitted inside the call, publishes those at or below the response's target_sequence, and releases the rest when the response has reached the caller. The call is a no-op for every other submit, the three bindings make it for you after they write the response, and a deadline releases the stream in order if a host forgets.

Subscriptions are the daemon's SSE connections without the socket: any number per session, bounded per-subscription buffers, and the same typed terminal signals — *serve.OverflowError carries the resume cursor (re-subscribe with serve.After(err.RunID, err.LastSequence)), and an expired cursor fails Subscribe with *adapter.ReplayGap naming what is still retained. hub.Sessions(ctx) lists every session with its adapter and lifecycle status (closed entries stay listed with their final state), and hub.CloseSessions(ctx) settles every session for shutdown — in-process hosts own their lifetime, since the restart-kills-sessions property of the daemon is a process boundary, not a library one. The hub adds no protocol semantics of its own and never validates envelopes; hosts forwarding untrusted input validate at their own boundary, exactly as servehttp does against the bundled schema.

Pick the tier that fits: adapter.Session directly for one embedded session (a single run stream with a single consumer, replay through Resume); serve for multi-adapter, multi-session hosts in process; goap serve plus client for out-of-process or non-Go consumers over HTTP + SSE.

Depending on the Go library

A Go program depends on this repository as a module, and one tag carries both the library and the released oapx binary:

go get github.com/lsm/open-agent-protocol@v0.1.0-alpha.4

Tags are shared with oapx releases, so a consumer that installs the library and a host that installs the binary are on the same pin. go get without a version takes the newest tag; a program that must stay on a known release should name it, the way this repository names its harness pins.

The public packages are the ones a program should import: protocol, adapter with every harness package under it (acp, claude, codex/appserver, deepseek, hermes, opencode, pi), client, harness, providercatalog, serve with its three bindings (servehttp, serveendpoint, servestdio), validation, and the embedded-data packages harnesses, providers and schema. Everything else lives under go/internal, and goap check fails on a package that is in neither set, so the list cannot drift by adding a directory. docs/go-library.md says what each of those is for and which file in goap uses it, which is where to start when the signature alone does not answer the question. docs/go-agent-loop.md covers the agent loop the Go tree is growing its own (#370): what oapx's loop does, which parts the Go tree needs first, and why the first slice is text turns and client-executed tool calls.

adapter.RunToTerminal is the shortest path from a prompt to a result: it submits, reads the stream, answers every interaction through a policy function, resumes after an overflow, and returns the run's response, usage, run id and tool-call count. The Go client and registry sections below show the same lifecycles written out by hand, which is what a host does when it needs to answer interactions itself.

Go client (client)

The client package is the far-side conformance proof for that wire: a public Go client that drives goap serve over HTTP + SSE, and the template later clients (TypeScript) copy. It speaks verbatim schema/v0.1 envelopes, consumes the event stream through a real text/event-stream parser, and resolves interactive gates as participant user by default:

c := client.New("127.0.0.1:6270")

session, _ := c.Open(ctx, "memory", "demo")
stream := session.Events(ctx)          // subscribe before submitting
session.Submit(ctx, protocol.MessageSubmitRequest{
	Messages: []protocol.Message{{Role: protocol.RoleUser, Content: protocol.TextContent("hi")}},
	Delivery: protocol.DeliveryAuto,
})
for {
	envelope, err := stream.Next()
	if err == io.EOF {
		break // the run reached its terminal event
	}
	// resolve action.permission.requested / user.input.requested as they
	// arrive, or collect content.delta / run.completed envelopes
}

Session.Events resumes invisibly: when the SSE connection drops, the client reconnects with the last observed sequence as the Last-Event-ID / ?after= cursor, the daemon replays the suffix, and the stream continues with no duplicates — a repeated or skipped sequence within a run is surfaced as an error, never silently accepted. WithStrictResume turns resume off and reports the drop as a DisconnectError carrying the cursor. The daemon's terminal signal events surface as typed errors: OverflowError (reconnect with Session.EventsAfter(RunID, LastSequence)) and ReplayGapError (the requested cursor expired; OldestAvailable/LatestAvailable bound what is retained). Daemon refusals arrive as ServerError with the correlated error.response code.

WithHTTPClient injects the underlying transport, WithParticipant overrides the responder identity, and WithEnvelopeValidation turns on dev-mode validation of every inbound envelope against the bundled schema (off by default). The client logs nothing. See example_test.go for the canonical lifecycle and the package documentation for the full surface.

TypeScript client (clients/ts)

The TypeScript client mirrors the Go one — same wire surface, same typed errors, same invisible resume — for non-Go hosts, with a zero-dependency runtime on platform fetch and streams (Node 18+ baseline):

const client = dial('127.0.0.1:6270');
const session = await client.open('memory', { sessionId: 'demo' });
const events = session.events();          // subscribe before submitting
await events.ready;
await session.submit({ messages: [{ role: 'user', content: 'hi' }], delivery: 'auto' });
for await (const envelope of events) {
  // resolve gates, collect deltas, end at run.completed
}

See clients/ts/README.md for the full surface. npm publishing is intentionally out of scope; the package is consumed from a checkout.

Harness pins:

Which upstream versions each adapter is proven against lives once, in harnesses/<id>.json, validated by harnesses/harness.schema.json (Decision 0033). A version records its status (one current; supported, floor, retired), endpoint version, capability revision, ledgers, corpus, and artifact digests per platform. Go and Zig both read their pins from it, so moving a pin touches no source file. goap check fails offline when the catalog disagrees with a ledger or a corpus's expectations, or when Go or Zig source spells a pin value as a literal.

Provider catalog:

Which model providers this repository supports lives once, in providers/catalog.json, validated by providers/catalog.schema.json. A row records a provider id, its display name, the credential kinds and the environment variable names it is read from, the wires it speaks, the base URLs it defaults to (with a region where a vendor ships one per region), where a models listing lives, and the origins an OAuth credential may be sent to. A row is a vendor's offering rather than the vendor itself, because a coding plan or a subscription is a different endpoint reached under the same credential: offering is coding_plan (prepaid bundle), subscription (a seat, reached by OAuth rather than a key) or api_key (metered tokens). A models_endpoint is the path appended to that row's base URL, so a base that already ends in a version segment takes /models, not /v1/models. status is where a row sits in the list — current for the providers it names first (OpenAI, Anthropic, OpenCode, OpenRouter, DeepSeek, Z.AI, Kimi, OpenAI Codex), supported for the rest, withheld for a row the list records but does not offer — and the list is ordered so every current row precedes every other. max_context_window is the largest window a model of the row can be given: a caller may lower the window a listing reported and raise it up to that figure, and the two OpenAI rows state 1000000 on the owner's word that every GPT model holds a 1M-token window. A row that states no ceiling does not have a large one — the window the listing or the catalog reports for a model is the ceiling in its place. Every member is optional, because an absent member records that the fact is unknown rather than that it is empty, and no credential is ever catalogued. A row with no status is supported, and a withheld row keeps its id reserved, so a providers.json entry still cannot shadow it.

Go embeds the file and goap check fails when a row is malformed, when one id is catalogued twice, when an offering or status is not a known value, or when Go or Zig source outside test code spells a catalogued base URL as a literal. Zig does not parse it at run time: build.zig turns the same file into typed rows (providerCatalogDataModule), so a base URL, a credential variable name or an origin policy that is not in the catalog does not compile. Add a row before adding a literal.

Research:

Implementing OAP natively

Every adapter in this repository translates a harness into OAP. An endpoint is the other direction: one agent loop that speaks OAP itself.

drafts/endpoint-stdio.md is the binding — OAP envelopes, one per line, over stdin and stdout. It is deliberately narrower than goap serve --stdio, which exposes a hub; an implementer should not have to build a registry, fourteen ops and multiplexed subscriptions to be conformant.

go run ./go/cmd/goap serve agent --backend memory # the reference endpoint: one adapter, raw envelopes
go run ./go/cmd/goap conformance                  # drive that reference endpoint and judge it
go run ./go/cmd/goap conformance --command "some-agent --oap" # drive somebody else's

oapx carries serve agent today; oapx conformance is product work under Decision 0038. goap is this repository's own tool.

The binding carries cursor replay as a transport control frame — the same place the HTTP binding puts it, where ?after= and Last-Event-ID are not envelopes either.

goap conformance spawns the command as a process, drives a scripted session over the binding, assembles every envelope it sent and received into a trace, and hands that trace to the same validator goap validate uses. The runner drives; the validator judges. Driving a process rather than linking a library is what keeps the runner usable against an endpoint written in any language.

Decisions:

Decision 0003 defines what accepted means and what moves a record from proposed to accepted. A record's own Status: line is authoritative; this table summarizes it. superseded records keep their wording and are listed so the chain stays readable, not because their clauses are in force.

Provider compatibility is tested independently from harness conformance. Inspect the credential-free China Coding Plan presets with:

go run ./go/cmd/goap providers zai-cn

Ordinary tests use fake credentials and loopback provider servers. Credentialed provider evidence is separately and explicitly gated as documented in the matrix; credential presence alone never enables network traffic.

Both Pi adapter trees reopen the recorded UUID and session file through switch_session. A missing, empty or mismatched file is refused before the native loader can create a new conversation. Recovered state reports the current model, thinking level and compaction switch; OAP runs and cursors do not survive. The loopback gate also verifies conversation history survives a new process, and OAP_PI_CAPTURE_DIR optionally records its native exchange.

Pi real-process checks are also explicitly opt-in and skipped by ordinary CI. Provide an absolute Pi v1.3.0 executable in OAP_PI_BIN, then set OAP_PI_SMOKE=1 for the credential-free readiness check or OAP_PI_INTEGRATION=1 for the loopback-provider path. The executable's reported semver is runtime-version evidence only; it does not prove the source commit. Set OAP_PI_SHA256 to the expected 64-character artifact digest when exact artifact provenance is required. The gate never downloads an executable and passes no ambient credentials to it.

DeepSeek Harness real-process checks follow the same opt-in gate. Provide an absolute runtime built from, or launched over, the pinned source commit in OAP_DEEPSEEK_HARNESS_BIN (the release publishes no binary), then set OAP_DEEPSEEK_HARNESS_SMOKE=1 for the credential-free initialize/shutdown check or OAP_DEEPSEEK_HARNESS_INTEGRATION=1 for the loopback-provider path. The gates never download a runtime and pass no ambient credentials. The runtime boots the shipped sdk profile (--profile sdk) against an isolated DSH_HOME; the loopback gate redirects the stock deepseek provider, which speaks the Messages API, with DEEPSEEK_BASE_URL. The wire serverInfo version and any release text are runtime-version evidence only; the pinned source commit and tree in the mapping ledger remain the provenance. Set OAP_DEEPSEEK_HARNESS_SHA256 to the expected 64-character artifact digest when exact artifact provenance is required.

Hermes agent real-process checks follow the same opt-in gate. Provide an absolute python interpreter in OAP_HERMES_BIN (able to import the pinned checkout's dependencies, which v0.22.2 declares only for Python 3.14) and the pinned hermes-agent v0.22.2 checkout in OAP_HERMES_ROOT (the gateway's cwd), then set OAP_HERMES_SMOKE=1 for the credential-free ready/session.create/EOF-teardown check or OAP_HERMES_INTEGRATION=1 for the loopback-provider paths (streaming OpenAI chat completions against an in-process mock, test-owned key only: a plain turn, and an approval answered through the gateway's server request). The gates never download anything and pass no ambient credentials; teardown evidence is stdin EOF, matching the pinned gateway, which has no shutdown RPC. They drop from the child's PATH every directory holding a copilot CLI, because the gateway starts one it finds there, which outlives it and writes into the isolated HOME (ledger). Set OAP_HERMES_SHA256 to the expected 64-character interpreter digest when exact artifact provenance is required.

Both Hermes adapter trees reopen the stored session they bound through session.resume. A reload Hermes would auto-continue after a crash is refused, as is one still running, and an unknown stored session answers 4007 and is refused. Recovered state reports the model the session last ran under; OAP runs and cursors do not survive. The integration gate also verifies the conversation survives a new gateway process, and OAP_HERMES_CAPTURE_DIR optionally records both native reload exchanges.

Claude Code real-process checks follow the same opt-in gate. Provide an absolute path to the pinned claude 2.1.302 binary in OAP_CLAUDE_BIN, then set OAP_CLAUDE_SMOKE=1 for the credential-free spawn/initialize/EOF-teardown check or OAP_CLAUDE_INTEGRATION=1 for the loopback-provider paths (streaming Anthropic Messages against an in-process mock, test-owned key only; structural request assertions only, per the mapping pin). The integration gate also drives a read-only review posture, AllowTools("Read", "Grep", "Glob") with an appended system prompt, and fails if any permission gate opens. It also submits an @ mention of a file outside the working directory and checks that the file reaches the provider only when Config.ExpandPrompts is set. The gates never download anything and pass no ambient credentials; readiness is the initialize control exchange, and teardown evidence is stdin EOF. Set OAP_CLAUDE_SHA256 to the expected 64-character binary digest when exact artifact provenance is required. OAP_CLAUDE_CAPTURE_DIR, an absolute directory outside the repository, records the corpus probes' raw stream-json in both directions into that directory; it is how fixtures/adapters/claude-code-2.1.302 was recorded.

Both ACP adapter trees reopen the binding's native session through the agent's advertised load or resume operation. Native load history stays outside the new OAP journal, and recovery reports the returned settings. The opt-in ACP gate also verifies conversation history survives close and reopen, as recorded in the current mapping ledger.

ACP real-process checks follow the same opt-in gate, driven against an independent open-source ACP agent rather than a Devin product. Provide an absolute docker-agent binary built from the pinned docker/cagent release (Apache-2.0, tag v1.143.0) in OAP_ACP_BIN, then set OAP_ACP_SMOKE=1 for the credential-free initialize/session/new/teardown check or OAP_ACP_INTEGRATION=1 for one prompt through the production adapter against an in-process loopback chat-completions mock. The generated agent file points base_url at the loopback endpoint; the checked-in real-provider examples are never reused. Building cagent needs Go 1.27. Set OAP_ACP_SHA256 to the expected 64-character artifact digest when exact artifact provenance is required. Devin CLI also speaks ACP (devin acp) but is proprietary and prebuilt-only, so it cannot be pinned as evidence.

Real-process gate coverage

CI runs gofmt, go vet, the full and race suites, and goap check on every push and pull request. The real-process gates above are not run in CI: they need pinned third-party binaries, are skip-by-default, and require an explicit opt-in variable plus an absolute binary path (and optionally a 64-hex digest). Credential presence alone never enables them, and they never download anything.

Adapter Gate variables CI
Codex app-server OAP_CODEX_INTEGRATION, _BIN, _COMMIT, _SHA256 skipped
OpenCode OAP_OPENCODE_INTEGRATION, _BIN, _SHA256 skipped
pi OAP_PI_SMOKE / OAP_PI_INTEGRATION, _BIN, _SHA256 skipped
DeepSeek Harness OAP_DEEPSEEK_HARNESS_SMOKE / _INTEGRATION, _BIN, _SHA256 skipped
Hermes OAP_HERMES_SMOKE / OAP_HERMES_INTEGRATION, _BIN, _ROOT, _SHA256 skipped
Claude Code OAP_CLAUDE_SMOKE / OAP_CLAUDE_INTEGRATION / OAP_CLAUDE_CAPTURE_DIR, _BIN, _SHA256 skipped
ACP (docker/cagent) OAP_ACP_SMOKE / OAP_ACP_INTEGRATION, _BIN, _SHA256 skipped

Each adapter additionally has a hermetic corpus that runs in CI: it decodes sanitized native frames through the production codec and drives the production reducer, so codec and reducer regressions are caught without any external process.

The repository is dedicated under CC0-1.0 so any presentation layer, control layer, agent loop, model provider, tool executor, resource provider, tool source, or SDK adapter can implement the protocol without project-specific licensing friction.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL