Control Protocol (OCP)
The Oasis Control Protocol is a versioned JSON-RPC 2.0 interface over newline-delimited JSON. It exposes every agent capability to any program that can open a bidirectional byte stream, with no terminal required.
For the complete method list, see the OCP Method Reference.
The client abstraction
There is exactly one notion of a client: any program that can open a bidirectional byte stream to the agent. No client gets a special case.
Two assumptions are deliberately not made:
- A client may be unable to hold a connection. Short-lived request handlers, CGI, serverless, cron.
- A client may be able to hold one, and must not be penalised for it. A daemon, a desktop app, the REPL.
From this follows the core rule:
A session's lifetime is completely independent of any connection's lifetime. Connections are ephemeral views onto session state the agent owns. Nothing a client does to a connection (dropping it, never opening one, opening six) can affect a running conversation.
Two access patterns, neither degraded
| Pattern | Method | For |
|---|---|---|
| Cursor | session.poll{fromSeq, waitMs} → events + nextSeq | Clients that cannot hold a connection. waitMs: 0 is a plain poll; waitMs > 0 parks until an event arrives or the deadline passes |
| Connected | session.attach{fromSeq} → pushed events | Clients that can hold a persistent connection |
Both read the same ordered event ring and see identical events in identical order. Neither is a fallback for the other.
An empty return at a long-poll deadline is a normal outcome, not an error.
Getting a stream
# Standard input / output: pipes, subprocesses
oasis-agent serve --stdio
# Unix domain socket, created 0600
oasis-agent serve --unix /run/oasis.sock
# Relay stdin/stdout to a socket the agent is serving
oasis-agent connect /run/oasis.sockconnect is what makes every tunnel work without the protocol knowing about the transport:
# Remote access, with no protocol support required for it
ssh host oasis-agent connect /run/oasis.socksocat, systemd socket activation, a container exec, a serial line and a hypervisor guest channel are all the same relay with a different pipe in front.
There is no network listener, no TLS and no TCP
Where a stream needs securing, the thing already carrying it secures it. This is why none of the transports above is named anywhere in the protocol.
One session per process
One session per process, and one connection at a time. This process owns one conversation and one message array; serving many conversations concurrently is a broker's job.
A client that disconnects and returns is served normally, which is the property that actually matters.
Framing
Newline-delimited JSON with JSON-RPC 2.0 envelopes. JSON escapes \n inside strings, so the delimiter is unambiguous with no additional escaping layer, and messages stay readable under nc and jq.
Two non-optional rules:
| Rule | Value | Why |
|---|---|---|
| Max frame size | 16 MiB, enforced, typed | Exceeding it produces a frame_too_large error, not a disconnect. A reader with no limit is a memory-exhaustion vulnerability in a protocol costume, and a disconnect tells the client nothing about which frame was rejected |
| Write coalescing | Stream events coalesce to at most 5 ms or 8 KiB | Deltas arrive roughly per token; a syscall each is thousands per turn |
Requests, responses, permission.required and elicitation.required flush immediately. Getting that backwards leaves a permission prompt sitting in a buffer.
The handshake
Every connection begins with hello. It is the only method accepted before the handshake, and it returns the protocol version plus a features[] array naming the methods this build actually answers.
Read features[] rather than assuming. A method that is declared but not enabled (see host-mutating methods) is absent from the array and answers unsupported.
Method scopes
Every method declares a scope, which describes its blast radius.
| Scope | Affects | Examples |
|---|---|---|
connection | This connection only | hello |
session | This conversation | turn.send, session.poll, permission.respond |
host | The machine, beyond this conversation | config.set, jobs.kill, update.apply, checks.run |
Host-mutating methods
Four methods reshape the host and are off by default:
config.setchecks.runaudit.runupdate.apply
Start the server with --allow-host-mutations to enable them:
oasis-agent serve --unix /run/oasis.sock --allow-host-mutationsWhy they are off by default
Authorisation is a separate piece of work. Until it lands, anyone who can open the socket can call anything, and update.apply replaces the binary while config.set writes settings that outlive the session.
Without the flag they are declared, absent from features[], and answer unsupported. The degradation path the protocol gives clients, used on ourselves first.
Permission keys are never writable
Even with --allow-host-mutations, config.set refuses these keys:
skip_permissions, mode, cwd, api_key, actor
They decide how much the model may do without asking, and they persist, so writing one would widen every future session on the host, from a socket that has no authorisation on it. Whoever starts the process sets them.
config.schema names the refused keys and the reason, so a client can explain the refusal to an operator rather than filing a bug.
History is append-only
There is no protocol method that edits history. Compaction is its only sanctioned mutation, and it is permission-gated. This is asserted by a test rather than left to review.
Events
Events carry a monotonic sequence number. Both access patterns take a fromSeq and return events from there, so a client that reconnects resumes exactly where it left off.
| Event | Meaning |
|---|---|
turn_started | A turn began. Echoes the client's idempotencyKey when one was supplied |
text_delta | Model output, as it arrives |
reasoning_delta | Reasoning output, as it arrives. A separate event, never a flavour of text_delta |
tool_use | The model called a tool. Carries parent when it ran inside a subagent |
tool_result | A tool call finished |
task_started / task_progress / task_finished | A background job's lifecycle |
cache_stats | Prefix-cache reuse for the request |
cache_bust | The prefix cache was invalidated, with the reason |
conversation_reset | The conversation was reset |
transcript_reloaded | History was reloaded from disk |
date_rolled_over | The date changed mid-conversation |
threading_repaired | A malformed tool-call thread was repaired |
reminder | A system reminder was injected |
turn_finished | The turn completed |
error | Something failed |
session_closed | The session ended |
gap | Events were dropped from the ring. The client fell too far behind |
Render reasoning_delta differently from text_delta
They are separate events on purpose. A client that renders the two the same way is showing the operator working notes as if they were an answer.
Out-of-band frames
Two frames interrupt the stream and are flushed immediately:
| Frame | Answered with |
|---|---|
permission.required | permission.respond |
elicitation.required | elicitation.respond |
A turn parked on either is not a stalled turn. It is waiting for you. Both carry the risk tier (safe, mutating, hard_stop) so a client can render a PLC write differently from a file write.
permission.respond accepts allow_once, allow_always, reject_once or reject_always.
Compaction
When history is compacted, the phase is reported as it progresses: planning → summarising → reprocessing → applied.
reprocessing is the phase that actually takes time. The next request pays a cold prefill of the whole compacted prompt. Surface it to the user rather than letting the connection look hung.
Versioning
The protocol is versioned (protocol: 1). Breaking changes ship as a new protocol version; additive changes (a new method, a new event) do not.
Read features[] from the hello response to discover what a given build answers, rather than inferring it from the version number.
Next
- OCP Method Reference: all 42 methods
- MCP Integration: the other direction: tools into the agent