Skip to content

Control Protocol (OCP)

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

PatternMethodFor
Cursorsession.poll{fromSeq, waitMs} → events + nextSeqClients that cannot hold a connection. waitMs: 0 is a plain poll; waitMs > 0 parks until an event arrives or the deadline passes
Connectedsession.attach{fromSeq} → pushed eventsClients 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

bash
# 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.sock

connect is what makes every tunnel work without the protocol knowing about the transport:

bash
# Remote access, with no protocol support required for it
ssh host oasis-agent connect /run/oasis.sock

socat, 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:

RuleValueWhy
Max frame size16 MiB, enforced, typedExceeding 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 coalescingStream events coalesce to at most 5 ms or 8 KiBDeltas 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.

ScopeAffectsExamples
connectionThis connection onlyhello
sessionThis conversationturn.send, session.poll, permission.respond
hostThe machine, beyond this conversationconfig.set, jobs.kill, update.apply, checks.run

Host-mutating methods

Four methods reshape the host and are off by default:

  • config.set
  • checks.run
  • audit.run
  • update.apply

Start the server with --allow-host-mutations to enable them:

bash
oasis-agent serve --unix /run/oasis.sock --allow-host-mutations

Why 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.

EventMeaning
turn_startedA turn began. Echoes the client's idempotencyKey when one was supplied
text_deltaModel output, as it arrives
reasoning_deltaReasoning output, as it arrives. A separate event, never a flavour of text_delta
tool_useThe model called a tool. Carries parent when it ran inside a subagent
tool_resultA tool call finished
task_started / task_progress / task_finishedA background job's lifecycle
cache_statsPrefix-cache reuse for the request
cache_bustThe prefix cache was invalidated, with the reason
conversation_resetThe conversation was reset
transcript_reloadedHistory was reloaded from disk
date_rolled_overThe date changed mid-conversation
threading_repairedA malformed tool-call thread was repaired
reminderA system reminder was injected
turn_finishedThe turn completed
errorSomething failed
session_closedThe session ended
gapEvents 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:

FrameAnswered with
permission.requiredpermission.respond
elicitation.requiredelicitation.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: planningsummarisingreprocessingapplied.

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

software-defined automation