A draft of the protocol written as data: the tables are the protocol, and the prose only explains them. It is not yet the standard; protocol v1 is. Once reviewed, it replaces that document, and the node's code becomes small engines that read these tables (KEEP.md, "Structure the data"). Section numbers here are this draft's. The words it adds are in section 13, for the maintainer to approve.
An OCX message is a JSON object in a fenced block of a forum post or a ticket change. A run's message is built in layers, like a network packet, and a node processes them outer to inner, in the order of table 1.
Notation: {"a", "b"} stands for a JSON object with the keys a and b,
whose values the table beside it gives. "The server's order" is the order the
server received artifacts in: by its receipt's number, then by the artifact's
row number (protocol v1, section 1).
1. Layers
| # | Layer | Key | Answers | Section |
|---|---|---|---|---|
| 1 | Envelope | ocx, kind, node, and run in every kind but run |
which version, what kind, for which node, about which run | 3 |
| 2 | Workspace | workspace |
the files the process works in, made step by step | 5 |
| 3 | Sandbox | sandbox |
what the process sees and may do | 6 |
| 4 | Limits | limits |
how much the process may use | 7 |
| 5 | Process | process |
what to run, as whom, when it starts, what it is given | 8 |
A run without workspace gets none, one without sandbox runs unconfined,
and one without limits runs unlimited. A node never runs a run with less
than it asks: what it cannot provide it refuses (section 11).
2. Values
Not a layer: how a string names something the node knows, written
$$name$$. The node sets the values of this table before it processes the
workspace, from the run itself and from the node; a workspace step sets more (section 5), for the steps,
layers and process after it. started lists every value set, as it was.
| Name | Value |
|---|---|
root |
the directory under which the node makes its runs' workspaces |
project |
the server's repository's name: the last part of the server's URL, or its file's name without extension |
user |
the run's process.user |
run |
the run's hash, in full |
node |
the node user |
server |
the server's URL, from which a run's clone is made |
release |
the directory of the OCX release the node runs, whose programs a process may use |
How a string names a value:
| Written | Read as |
|---|---|
$$, a name of letters, digits or _, then $$ |
the value of that name; a run naming a value not set by then is refused (section 11, row 3) |
| everything else | itself, $ and { included, so a shell's ${HOME} or $$ reach the process as written |
Values are read in every string of workspace, sandbox and process, at
any depth, but process.user, process.deliveries, process.hears and
process.checkins, and never in an object's keys. The node checks every
value a run names when the run arrives, before any step, knowing which value
each step sets: a run naming a value that nothing sets before it is refused
then, and nothing is made. A default (section 8) is
read the same way, but a default that names a value not set is left out
instead of refused.
3. Message kinds
| Kind | Sent by | Written where | Its fields | Answered by |
|---|---|---|---|---|
run |
a user holding admin or setup | anywhere: a new thread, a reply, a ticket change | ocx, kind, node, objective, and the layers of table 1 |
started then ended, or ended alone |
stop |
a user holding admin or setup | where its run is | ocx, kind, node, run |
its run's ended |
started |
the node, as its node user | where its run is | ocx, kind, node, run, values, limits |
none |
ended |
the node, as its node user | where its run is | ocx, kind, node, run, outcome, exit, note (table 12) |
none |
objective (text, required) says what the run is for, and what counts as
passing. run, in every kind but run, is the run's hash in full.
Who sent a message is the user the server received it from (section 8 of protocol v1), never the name written in it. A message is read from the first version of its post or change: an edit is ignored.
| A message that | Is |
|---|---|
is a run sent by anyone the table does not name |
answered ended, refused |
is a stop, started or ended sent by anyone the table does not name, or written anywhere else |
ignored |
has a kind the reader does not know |
ignored |
is a second started or ended for one run |
ignored: the first, in the server's order, counts |
4. What the node keeps
The repository holds every message; the node keeps, on its own host, only what a running process needs, and nothing a later reader depends on.
| Kept | For |
|---|---|
which runs it has in its care: from just before the workspace is made until ended is written |
so it acts on each run once, and finds what its going down left (section 10) |
| what each session in its care has been given | so it gives each artifact at most once |
the deliveries waiting for a session whose deliveries is start |
so each process gets one |
| its file of passwords, on a node that is not the server's | the run users it may run as (section 5, password) |
5. The workspace layer
The workspace is a list of steps, run in order. A step is an object naming its
kind in step, with that kind's arguments, which may name values (section
2). A step that fails ends the run refused, its note naming the step. The
process runs as the node's own account, in the workspace the steps make.
| Step | Arguments | Needs | Does | Sets |
|---|---|---|---|---|
directory |
at |
none | makes the directory, removing what was there, readable by the node's account alone | workspace |
password |
none | none | takes the run user's password: the server's node sets a fresh one (it runs one run at a time for each user); a node on another host reads it from its file of passwords | none (a password is never a value) |
clone |
into |
password |
clones the server as the run's user into into, remembering the password |
clone |
checkout |
of, into |
clone |
opens a checkout of the check-in of names (any name Fossil resolves), resolved once, into into |
tree, and checkout: the check-in in full |
The steps most runs take, which a pattern holds once:
"workspace": [
{"step": "directory", "at": "$$root$$/$$user$$/projects/$$project$$/$$run$$"},
{"step": "password"},
{"step": "clone", "into": "$$workspace$$/clone"},
{"step": "checkout", "of": "trunk", "into": "$$workspace$$/tree"}
]
6. The sandbox layer
What the process sees and may do. Every field is required, so the run says
all of it. The node translates the fields into its confining program's
arguments; on Linux, bwrap's. Every path is absolute once its values are in
place, and the fields are applied in this table's order, so a later one is
seen over an earlier one at the same place.
| Field | Type | Meaning |
|---|---|---|
uid, gid |
whole numbers | the user and group the process runs as inside |
capabilities |
list of names | the Linux capabilities (not Fossil's) it keeps; empty keeps none |
network |
true or false | whether it shares the host's network; it never sees the host's processes, users, host name or message queues |
own_session |
true or false | whether it runs in a session of its own, so it cannot reach a terminal outside |
ends_with_node |
true or false | whether it ends when the node's process that watches the run ends |
read_only |
list of paths | host paths it sees read-only, each where it is on the host |
read_write |
list of paths | host paths it sees read-write, each where it is on the host; a path in both lists is read-write |
homes |
list of paths | host paths it sees read-write inside the workspace, each by its last name: /root/.claude as $$workspace$$/.claude |
files |
list of {"at", "text"} |
files made for it alone: at a path, text what the file holds |
proc, dev, tmp |
paths | where it sees its own /proc, its own /dev, and an empty directory for temporary files |
Nothing else of the host is visible to it. A path in read_only,
read_write or homes is refused (section 11, row 9) when, once links are
followed, it is or holds or lies within one of the node's own files:
| The node's own files | But |
|---|---|
| its repository | |
| the directory it keeps its runs' state in | |
| its release root, which holds its programs and every run's workspace | $$release$$ and $$workspace$$ themselves may be named |
| its file of passwords |
7. The limits layer
| Field | Type | Counted from | Enforced, on Linux, by | Past it |
|---|---|---|---|---|
wall |
a whole number and s, m, h or d |
the launch | the node ending the process | ended, budget |
cpu |
whole seconds | the launch, for each process | the kernel, signal SIGXCPU | ended, budget |
processes |
a whole number | at any moment | the kernel, for the sandbox's own user | a new process is not started |
memory |
a size (section 8, keep) |
at any moment | a cgroup: not yet, so a node refuses it | ended, budget |
started lists, in limits, the limits applied, as written.
8. The process layer
| Field | Type | Default | Meaning |
|---|---|---|---|
user |
text | required | the Fossil user the process acts as; never a node's, or one holding admin or setup |
command |
list of text | required | the program, then its arguments; a program named without / is found on env's PATH |
env |
object of text | empty | the process's whole environment; the node adds nothing |
cwd |
text | $$workspace$$, or / with no workspace |
where it starts |
manual |
true or false | false | false: the node starts it when the run reaches the node; true, a manual run: it waits until a user holding admin or setup starts it, with OCX's run tool |
session |
true or false | false | false: it runs to completion; true: it is given what it subscribes to (section 9) |
hears |
list of users, or ["*"] for every user |
empty | whose posts and ticket changes a session is given |
checkins |
list of branch names | empty | the branches whose check-ins a session is given |
deliveries |
feed or start |
feed |
feed: each delivery goes to the running process; start: each starts a process of its own, the same command, one at a time |
terminal |
true or false | false | the process runs on a terminal the node holds, which a person may join with OCX's attach |
keep |
{"stderr", "stdout", "stdout_when"} |
64K, 64K, failure |
how much of the end of STDERR and STDOUT the node keeps; STDERR always, STDOUT on failure (an exit but 0, or a signal) or always |
A size is a whole number of bytes, or text: a whole number and K, M or
G, for 1024, 1024² or 1024³ bytes.
8.1 Streams
| Stream | Without a terminal | With a terminal |
|---|---|---|
| STDIN | the line {"run", "objective"}, run the run's hash and objective its text; then each delivery, one line each, for a session that feeds; otherwise closed |
the terminal: the first line and each delivery typed in, and what a person joined types |
| STDOUT | a pipe the node reads | the terminal, which a person joined sees |
| STDERR | a pipe the node reads | a pipe of its own, which the node copies to the terminal |
A process a delivery starts reads the line {"run", "objective"}, then the
delivery's line, then its input closes; with a terminal, each such process has
a terminal of its own.
8.2 Kept logs and where reports go
| When | The node writes | As |
|---|---|---|
| a process exits, and a stream it keeps printed at least one byte | $$workspace$$/stderr.log and $$workspace$$/stdout.log, when the run has a workspace |
files, rewritten for each process |
| a process exits, and a stream it keeps printed at least one byte | a reply where the run is: STDERR's and STDOUT's ends, each in a fenced block headed stderr or stdout, and its exit |
a plain post or ticket change, holding no OCX message |
| the run ends | ended |
a reply in the run's thread, or a change to its ticket |
Where a run is, a forum thread or a ticket, decides only where its reports and kept logs are written and what they point to.
9. Subscriptions
What a session is given. An artifact that arrives is checked against these filters in order, and given only if it passes every one, at most once, in the server's order.
| # | Filter | Passes when |
|---|---|---|
| 1 | session | the run's process.session is true, and its started counts (section 3) |
| 2 | after | the server received it after the run's started |
| 3 | known | the server has said who sent it |
| 4 | no message | it holds no OCX message |
| 5 | not its own | its sender is not the run's process.user |
| 6 | where | a post in the run's thread, or a change to its ticket, or a check-in on a branch in checkins |
| 7 | heard | a check-in; or a post or change whose sender is in hears, or hears is ["*"] |
| Arrives | Line, a JSON object |
|---|---|
| a post | post: its hash; from: its sender; text: its text |
| a ticket change | change: its hash; ticket: the ticket; from; text: the text it adds |
| a check-in | checkin: its hash; branch: its branch as Fossil's timeline shows it; from; comment: its comment |
10. A run's life
The run's state is read from the repository, started and ended that count,
and from whether the node has the run in its care (section 4).
| State | What arrives | Next state | The node writes or does |
|---|---|---|---|
| none | a run that section 11 refuses | ended | ended, refused, and the note |
| none | a run, manual false |
started | takes it into its care, runs layers 2 to 4, writes started, then launches the command |
| none | a manual run | waiting | nothing |
| waiting | a user holding admin or setup starts it | started | as for a run that is not manual; a refusal then ends it refused |
| none or waiting | a stop | ended | ended, stopped |
| started | a delivery (section 9), deliveries feed |
started | the delivery to its STDIN |
| started | a delivery, deliveries start |
started | a process for it, once the one before has exited; deliveries wait in the server's order |
| started | a stop | started | ends the process |
| started | its process exits; not a session whose deliveries is start |
ended | kept logs, then ended: exited, budget or stopped (table 12) |
| started | a process exits, in a session whose deliveries is start |
started | kept logs |
| started | a stop, or past wall, in a session whose deliveries is start |
ended | ends its process, kept logs, then ended, stopped or budget |
| started | the node finds it not in its care | ended | ended, lost |
| ended | anything | ended | nothing |
The node finds what is not in its care when it starts, and whenever its
administrator runs that check again. It writes started before it launches,
so a process is launched at most once for a run and others see it began; a
node on another host sends started to the server and launches only once
the server has it.
11. Refusals
A run is checked against these in order, when it arrives and again when a
person starts it; the first that holds ends it refused, with its note.
| # | Holds when | Note names |
|---|---|---|
| 1 | no user holding admin or setup sent it | that |
| 2 | a required field is missing, or a field has the wrong type or form | the field |
| 3 | it names a step kind section 5 does not have, a step whose needs are not met before it, or a value not set by then | which |
| 4 | its user is the node's own, a node's (one named as the node of a run an admin or setup sent), or holds admin or setup |
which |
| 5 | its user works for another node: the node of the first run, in the server's order, that an admin or setup sent with that process.user, whatever became of it |
which node |
| 6 | it has a password step, and the node, on another host, holds no password for its user |
the user |
| 7 | it has no sandbox, and the node runs nothing unconfined |
that |
| 8 | it asks a sandbox setting or a limit the node cannot provide | which |
| 9 | a sandbox path reaches the node's own files (section 6) | the path |
| 10 | a workspace step fails | the step, and why |
12. ended
outcome |
When | exit |
note |
|---|---|---|---|
refused |
section 11 | absent | required: plain text, as for every note |
stopped |
a stop ended it, or came first | absent | absent |
exited |
its process exited by itself | its code; 128 and N for signal N | absent |
budget |
past a limit | absent | required: which limit |
lost |
the node found it not in its care | absent | required |
13. Words for the maintainer to approve
| Word | Meaning here | Instead of |
|---|---|---|
| layer | one level of a run's message, table 1 | |
| envelope | the fields every message carries, so any reader knows what it is | |
| value | something the node knows, named $$name$$ in a string |
macro |
| step | one action of the workspace layer | tool, which GLOSSARY.md gives OCX's commands |
| process | a program running for a run | the glossary's program, for what runs |
| in its care | a run the node is preparing or running | |
| works for | a run user bound to one node by its first run | |
| kept logs | the ends of STDERR and STDOUT the node keeps and posts | |
| manual run | a run whose process waits until a person starts it | when, hand |
14. An unconfined echo, and Claude in a sandbox
{"ocx": 1, "kind": "run", "node": "lab-1", "objective": "Say hello.",
"process": {"user": "builder", "command": ["echo", "hello"], "env": {"PATH": "/usr/bin:/bin"},
"keep": {"stdout_when": "always"}}}
The node writes started, runs echo hello in /, posts a reply holding
hello, then ended, exited, exit 0.
{"ocx": 1, "kind": "run", "node": "lab-1", "objective": "Answer what is said here.",
"workspace": [
{"step": "directory", "at": "$$root$$/$$user$$/projects/$$project$$/$$run$$"},
{"step": "password"},
{"step": "clone", "into": "$$workspace$$/clone"},
{"step": "checkout", "of": "trunk", "into": "$$workspace$$/tree"}],
"sandbox": {"uid": 1000, "gid": 1000, "capabilities": [], "network": true, "own_session": true,
"ends_with_node": true,
"read_only": ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs", "$$release$$"],
"read_write": ["$$workspace$$"],
"homes": ["/root/.claude", "/root/.claude.json"],
"files": [{"at": "/etc/passwd", "text": "$$user$$:x:1000:1000::$$workspace$$:/bin/sh\n"}],
"proc": "/proc", "dev": "/dev", "tmp": "/tmp"},
"limits": {"wall": "8h"},
"process": {"user": "claude-1", "command": ["claude", "--remote-control"], "cwd": "$$tree$$",
"session": true, "hears": ["*"], "terminal": true,
"env": {"HOME": "$$workspace$$", "USER": "$$user$$", "PATH": "$$release$$:/usr/local/bin:/usr/bin:/bin",
"IS_SANDBOX": "1"}}}
A pattern, by convention the wiki page run/NAME, holds such fields once;
create writes them into each run in full.
Open
- Starting a run on an event other than its arrival (2f2c0068cc).
- A shell in a run's sandbox,
ocx2 run RUN -- /bin/sh(2f2c0068cc). - How a node says it runs nothing unconfined: today an option of the node.
- A workspace step that runs a command of the run's choosing.
- A filter on the kept logs, beyond their sizes.
- Moving
envto the process layer and the budget to the limits layer amends the approved sandbox of 62e45fcde6, in which both sat insandbox: an unconfined process has an environment too, and limits are a layer of their own (2026-10-13).