OCX extends Fossil, so people and programs can ask a node to run a process, talk with it, learn when it ends, and end it. This is the protocol, written as data: the tables are the protocol, and the prose only explains them, so a node's code is small engines that read them (KEEP.md, "Structure the data"). What it rests on, Fossil as the medium, who sent an artifact, nodes and versions, is Fossil, senders and nodes. The maintainer promoted it on 2026-10-13 (HISTORY.md decisions 44 and 46), and it is stable: section 15 says what may be added and what never changes.
What it is for
Each row is a problem we have, what version 1 does about it, and the virtue of README.md it serves.
| Problem | What version 1 does | Virtue |
|---|---|---|
| Work must run on machines no one can reach from outside, such as a build machine or a lab board behind its own network | a node pulls runs from the server and pushes its reports; nothing listens for a connection, and no port is opened | Loose Coupling |
| Afterwards, no one can say what ran, who asked for it, what it was given and how it ended | every run, stop, report, delivery and kept log is an artifact in the repository, its sender the user the server received it from | Demonstrability |
| An agent or program must not hold the keys that could change what it is allowed | only a user holding admin or setup can send a run; a run's user is never one, and a node's user holds no capability on the server | Clarity |
| What a process may see and use must be known before it starts, not learned by watching it | the run states its sandbox and limits in full; the node adds nothing, records the values and limits it applied in started, and refuses what it cannot provide |
Clarity, Parsimony |
| Asking for work must not depend on one program or on being online | a message is a JSON block in a forum post or ticket change; Fossil's web pages, any program, or a clone offline (a ticket change, or OCX's post) can write one, and OCX's tools are a convenience, but for starting a manual run at its node |
Loose Coupling |
| Talking with a running agent must not need a connection to it | a session is given each post, ticket change or check-in it subscribes to, a line of JSON on its standard input; it answers by posting | Loose Coupling, Parsimony |
| A run must never be run twice, nor end unanswered | a node writes started before it launches, so it launches at most once; every run it acts on gets one ended, and one its going down left is answered lost when it starts again |
Demonstrability |
| The node must be replaceable, and readable by people and machines | the protocol is these tables; a node is small engines that read them, and another can be written from these pages | Loose Coupling, Clarity |
| Each moving part is another way to fail | no daemon: a node acts when artifacts arrive, from the server's hook or a pull, a watcher lives only as long as its run, and the node keeps on its host only what its runs need (section 4) | Parsimony |
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 (Fossil, senders and nodes, 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: the hash
of the forum post or ticket change artifact that holds the run.
Who sent a message is the user the server received it from (Fossil, senders and nodes, section 3), 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, within $$root$$ |
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, within $$workspace$$ |
password |
clones the server as the run's user into into, remembering the password |
clone |
checkout |
of; into, within $$workspace$$ |
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 |
"Within" means below that directory, not the directory itself, once links are
followed. A directory whose at is not within $$root$$, or is or holds
another run's workspace while that run is in the node's care, and a clone
or checkout whose into is not within $$workspace$$, are refused when the
run arrives, before any step (section 11, row 9): a step never removes or
writes anything outside the run's own place.
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 |
proc, dev, tmp |
paths | where it sees its own /proc, its own /dev, and an empty directory for temporary files |
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 base name: /root/.claude as $$workspace$$/.claude |
files |
list of {"at", "text"} |
files made for it alone: at a path, text what the file holds |
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; a session's first, when each delivery starts a process | 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 |
files |
a size (section 8, keep) |
each file a process writes | the kernel, signal SIGXFSZ | ended, budget |
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, each key on its own |
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, nor a node's 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 |
| The node guarantees | That is |
|---|---|
| whole, once, in order | each delivery reaches the session whole, at most once, one at a time, in the server's order |
| only what arrives | a session is given what its node acts on as it arrives; one the node missed, as when its hook failed, is not given later, and the session may read its thread itself |
| known senders first | on a node on another host, delivery stops at an artifact whose sender the server has not said, so none after it is given before it |
| bounded | what waits for a process that does not read is held up to a bound the node sets; past it, nothing more is given until the process reads, and the node's log names what it could not give |
An edit of a post is a post, and is given. To speak to one session alone, give it a thread or a ticket of its own.
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, the run not refused by rows 1 or 2 of section 11 | ended | ended, stopped, even where rows 3 to 10 would refuse it |
| none | a run whose user has another run in the node's care | waiting | nothing, until that run ends |
| 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, a field has the wrong type or form, or it has a key version 1 does not define (section 15) | the field or key |
| 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), or a workspace step's path is not where section 5 allows | 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 plus 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. Its words
| 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 |
| session | a run whose process.session is true, given deliveries (section 9) |
|
| node user, run user | the Fossil user a node writes as; the Fossil user a run's process acts as | login, agent |
14. Examples
Whole messages, each with what a node writes back, are protocol v1's examples.
15. Stability
Every message carries "ocx": 1. Version 1 grows only by addition, and a
reader can tell what it does not know.
| Change | Within version 1 |
|---|---|
| a new key, in any layer or kind, whose absence means what version 1 says without it | allowed |
a new message kind, a step kind (section 5), a value name (section 2), an outcome of ended, a refusal row |
allowed |
| renaming a key, changing its meaning, its type or its default, or removing it | never: that is version 2 |
a node that does not know a key in a run |
refuses the run, naming the key (section 11, row 2), so nothing asked is silently dropped |
a reader that does not know a key in a stop, started or ended |
ignores the key |
a reader that does not know a kind |
ignores the message, so a new kind asks nothing of an older node |
a reader that does not know an outcome of ended |
takes the run as ended |
A post may carry a block for each version; a reader takes the block of the newest version it knows (Fossil, senders and nodes, section 2). These rules bind version 1 from its promotion on 2026-10-13: no version 1 message was written on the server before it.
Open: possible additions, none changing version 1
- 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.