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 installputs 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
.htmlfile as source. fixtures/: normative executable conformance tracesfixtures/packs/: extension packs, loadable withgo run ./go/cmd/goap validate -packschema/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:
- Harness interoperability study
- P0 protocol gaps from harness interoperability
- Pinned Codex app-server mapping
- Pinned OpenCode server mapping, over the v2.0.24 mapping
- Pinned ACP v1 and Devin Desktop mapping, over the v1.11.0, v1.10.2 and v1.7.0 base mapping
- Pinned Pi coding-agent mapping, over the v1.1.0, v1.0.4, v1.0.1, v0.87.1 and v0.85.1 base mappings
- Pinned DeepSeek Harness mapping, over the dsh-v0.2.1-alpha.2, dsh-v0.1.7-rc.2 and 47f9438 base mappings
- Pinned Hermes agent mapping, over the v0.21.6, v2026.9.24 and v2026.8.31 base mappings
- Pinned Claude Code CLI and Agent SDK mapping, over the 2.1.296, 2.1.288, 2.1.282 and 2.1.280 mappings and the 2.1.263 base mapping
- Z.ai China Coding Plan evidence matrix
- OpenCode provider breadth and its model catalog, with Decision 0035 accepted on it
- Protocol feedback from eight adapter tranches
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:
- 0001 — agent-control v0.1 executable core (accepted)
- 0002 — admission before started (accepted)
- 0003 — graduating staged control units (accepted)
- 0004 — extension packs (accepted)
- 0005 — run controls (accepted)
- 0006 — models catalog (accepted)
- 0007 — queue delivery (accepted)
- 0008 — tool sources (accepted)
- 0009 — compound open (accepted)
- 0010 — terminal provenance (accepted)
- 0011 — control-layer-provided tools (accepted)
- 0012 — retire
+persistence, stage transcript load (accepted) - 0013 — steer (accepted)
- 0014 — provider descriptors (accepted)
- 0015 — evidence from implementations we do not control (accepted)
- 0016 — the model provider profile (proposed)
- 0017 — provider provisioning (superseded before graduation by 0028)
- 0018 — makai becomes first-party (accepted)
- 0019 — one binary, and the rewrite that gets there (accepted; its oracle and retirement clauses are superseded by 0032)
- 0020 — error codes are declared, not enumerated (proposed)
- 0021 — a tool policy says whether it outlives its run (proposed)
- 0022 —
tool_choiceis a filter here and a mode there (accepted) - 0023 — a denylist does not assert the catalog (accepted)
- 0024 — the filter judges the session catalog (accepted)
- 0025 — authentication is an obligation, not a credential channel (superseded by 0029 before acceptance)
- 0026 — a resume cursor may name its run (accepted)
- 0027 — compose profiles on one stdio connection (proposed)
- 0028 — live session model and provider control (proposed)
- 0029 — authentication over agent control (accepted)
- 0030 — remote model providers over HTTP (proposed)
- 0031 — a policy refusal is a settlement (accepted)
- 0032 — go and zig are peers, and the specification decides (accepted)
- 0033 — harness pins are data (accepted)
- 0034 — an unpublished catalog is unknown (accepted)
- 0035 — a model entry publishes its facts, and absence means unknown (accepted)
- 0036 — a presentation layer is not evidence for its own profile (accepted)
- 0037 — presentation state is versioned, and every intent is idempotent (proposed)
- 0038 — one released binary, and a library for every language (accepted)
- 0039 — a session is OAP's, and a harness is where it runs (accepted)
- 0040 — a session reopens through its own binding (accepted)
- 0041 — cancel acceptance is judged when the cancel is checked (accepted)
- 0043 — auth providers carry how they accept a credential (accepted)
- 0044 — compaction (accepted)
- 0045 — reasoning level and compaction policy are session settings (accepted)
- 0046 — a session list is the host's bindings (accepted)
- 0047 — a work profile over sessions (proposed)
- 0052 — rust is the third peer, and ports all of go (proposed)
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.
Directories
¶
| Path | Synopsis |
|---|---|
|
go
|
|
|
cmd/apidiffcheck
command
|
|
|
cmd/goap
command
|
|
|
sdk/examples/command-result
command
|
|
|
tools/goprintable
command
|
|
|
tools/nocomment
command
|
|