Runloop Message Protocol (RMP v0)
Doc status: Normative for framing, headers, TTL/duplicate handling, and error taxonomy. Last updated: 2025-11-13.
RMP v0 defines a binary envelope for agent messages on the local Runloop bus. The protocol is frozen for v0 under the rules below.
1. Wire framing (normative)
- Byte order: every multi-byte integer is encoded in big-endian (network order). Parsers MUST reject host-endian variants.
- Transport: streaming transports (Unix domain sockets by default). Frames may not be reordered or partially replayed.
- Frame format:
u32 frame_lenprefix that coversheader_len + body_len(does not include the prefix itself).- Fixed 64-byte header (see below).
body_lenbytes (MsgPack map body).
If the prefix is ever removed in a future transport,
header_len + body_lenMUST remain the delimiter.
1.1 Fixed header (big-endian, 64 bytes)
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 4 | magic | ASCII "RMP0" (0x52 0x4D 0x50 0x30) |
| 4 | 2 | header_version | 0 for RMP v0; anything else → UnsupportedVersion |
| 6 | 2 | header_len | 64, compare directly; mismatch → UnsupportedVersion |
| 8 | 4 | flags | MUST be 0 in v0; non-zero → InvalidHeaderFlags |
| 12 | 2 | schema_id | u16 primitive family ID (see registry) |
| 14 | 2 | reserved2 | 0; otherwise reject |
| 16 | 4 | body_len | u32 body length in bytes |
| 20 | 8 | created_at_ms | u64 epoch milliseconds |
| 28 | 8 | ttl_ms | u64 relative TTL; 0 → InvalidTtl |
| 36 | 16 | trace_id | u128 routed end-to-end |
| 52 | 8 | msg_id | u64 monotonic per publisher |
| 60 | 4 | reserved4 | 0; non-zero reject |
Reserved fields (reserved2, reserved4) and all flag bits MUST be
zero until RMP v1 negotiates new semantics.
1.2 Field requirements
magic/header_version/header_lenare validated before parsing the rest of the header; a truncated 64-byte header raises TruncatedHeader.schema_ididentifies a primitive family (Observation, Intent, Artifact, ToolResult, Critique, StateDelta, ErrorReport, etc.). The registry lives indocs/rmp-registry.md.body_lenMUST match the actual MsgPack payload length and participates in the framing equality checks below.trace_id+msg_idare used for dedupe and telemetry.
2. Body envelope (MsgPack map)
Body bytes are encoded as a MsgPack map of the form:
{ "type": "<family.kind.vN>", "payload": <object|bytes>, "meta"?: <map> }
- The header
schema_idselects the primitive family (Observation, Intent, Artifact, ToolResult, Critique, StateDelta, ErrorReport, etc.). - The
"type"string is the canonical registry entry (e.g.,"artifact.created.v1","error.report.v1") and MUST parse as<family>.<kind>.<version>where the family prefix matches the primitive. - Receivers MUST cross-check
schema_idwith the body type; mismatches raise BodyTypeMismatch. metais optional and carries diagnostics (opening_id,priority, tags, budgeting hints). Unknown keys MUST be ignored.opening_idlives inmeta, not the fixed header, in v0.
3. Framing invariants
frame_lenMUST equalheader_len + body_len. Any mismatch yields LengthMismatch and the frame is dropped.header_lenis fixed at 64 bytes in v0. Future versions MUST bumpheader_versionand, if necessary,header_len.- Implementations MAY cache
header_len, but they MUST still compare the actual field to64and reject anything else. - Ladder bytes (rlp trace): ladder hops render
frame_lenexactly as transmitted (header_len + body_len) and fall back to64 + body_lenwhen a pre-serialized frame length is unavailable (e.g., synthetic local runs).
4. TTL & expiry handling
- Compute
expires_at_ms = created_at_ms + ttl_msusing u128 arithmetic. - If
ttl_ms == 0, raise InvalidTtl before any delivery attempts. - If the addition overflows
u128or the resulting value does not fit inu64, raise InvalidExpiry. - Receivers MUST drop frames once
now_ms >= expires_at_ms, emitting Expired in counters/telemetry. Drops still publish the body torlp/sys/drops(see below) so operators can inspect failure causes.
5. Duplicate suppression & drop telemetry
- Dedupe scope: per (topic + subscriber instance).
- Key:
(trace_id, msg_id)tracked in a bounded LRU whose max-age is at least the configured max TTL. - Dropping a frame for TTL, Duplicate, or back-pressure MUST:
- Increment
drops_total{reason=...}metrics. - Publish a structured event on
rlp/sys/dropswith{reason, topic, trace_id, msg_id, expires_at_ms?}. Emitters MUST rate-limit this topic to avoid storms.
- Increment
6. Limits & safety
- Max
body_len: configurable, default 8 MiB. Larger frames are rejected with BodyTooLarge before touching MsgPack state. - Unknown
schema_id: reject with UnknownSchema. - Unsupported
header_version: reject with UnsupportedVersion. - Reserved fields / flags: reject non-zero values with InvalidHeaderFlags.
- Body decoding: MsgPack parse failures raise BodyDecodeError.
7. Error taxonomy (test oracle)
| Error | One-liner |
|---|---|
InvalidMagic | magic bytes were not "RMP0". |
UnsupportedVersion | header_version or header_len differed from 0/64. |
TruncatedHeader | Fewer than 64 header bytes were available. |
InvalidHeaderFlags | Non-zero flags, reserved2, or reserved4 encountered in v0. |
LengthMismatch | frame_len != header_len + body_len. |
UnknownSchema | schema_id not present in the registry. |
BodyTooLarge | body_len exceeded the configured limit. |
InvalidTtl | ttl_ms was zero. |
InvalidExpiry | created_at_ms + ttl_ms overflowed u128 or could not fit in u64. |
Expired | now_ms >= expires_at_ms at receipt time. |
Duplicate | (trace_id, msg_id) already seen within the dedupe horizon. |
BodyDecodeError | MsgPack body failed to parse according to the schema. |
BodyTypeMismatch | Body "type" string did not match the family implied by schema_id. |
Test suites MUST pin at least one fixture for each entry above.
8. Example: frame hex dump
A minimal ErrorReport frame with schema_id = 0x000A and body:
{
"type": "error.report.v1",
"payload": {
"code": "tool.unavailable",
"message": "mailer offline"
},
"meta": {
"opening_id": 1234
}
}
0000: 00 00 00 a0 52 4d 50 30 00 00 00 40 00 00 00 00
0010: 00 0a 00 00 00 00 00 60 00 00 01 93 23 64 5c 7b
0020: 00 00 00 00 00 00 ea 60 11 22 33 44 55 66 77 88
0030: 99 aa bb cc dd ee ff 00 00 00 00 00 00 00 00 2a
0040: 00 00 00 00 83 a4 74 79 70 65 af 65 72 72 6f 72
0050: 2e 72 65 70 6f 72 74 2e 76 31 a7 70 61 79 6c 6f
0060: 61 64 82 a4 63 6f 64 65 b0 74 6f 6f 6c 2e 75 6e
0070: 61 76 61 69 6c 61 62 6c 65 a7 6d 65 73 73 61 67
0080: 65 ae 6d 61 69 6c 65 72 20 6f 66 66 6c 69 6e 65
0090: a4 6d 65 74 61 81 aa 6f 70 65 6e 69 6e 67 5f 69
00a0: 64 cd 04 d2
- Bytes
0000–0003:frame_len = 0x000000a0 = 160(64 + 96). - Bytes
0004–003f: fixed header (magic, version, len, flags, schema, body length, timestamps, TTL, IDs, reserved zeros). - Bytes
0040–00a3: MsgPack body (three-entry map withmeta.opening_id).
Use this vector as a golden test for codecs; decoding should produce the header fields above and the stated body map exactly.
9. Control plane (MVP)
The control plane uses the same bus and framing:
- Topic:
rlp/ctrl. - Submit:
CT_CTRL_REQwithControlRequest::RunSubmit { request_id, opening_yaml }.- The CLI applies any
--paramsoverride and sends the merged YAML asopening_yaml. - The frame
header.trace_idMUST equalrequest_idfor correlation. ttl_ms = 30000(30s) for control requests.
- The CLI applies any
- Response:
CT_CTRL_RESPwithControlResponse::{RunAccepted, RunRejected, RunCancelled}. RunAcceptedcarries{ request_id, trace_id, opening_id, opening_name }.- Idempotency: the daemon MUST treat duplicate
CT_CTRL_REQwith identicaltrace_idas idempotent. - Acceptance timeout: the CLI waits up to 2000 ms for
RunAcceptedbefore failing with guidance to start the daemon or use--local. - Rejections:
RunRejectedincludes a humanreason. Future protocol revisions may add structuredcode/detailfields once the daemon emits them; the CLI surfaces the textual reason today.
9.1 Executor ↔ agent envelopes
-
Topic namespace:
agent/<agent_ref>(one topic per logical agent). The executor publishes requests here. Each request includes a uniquereply_topic(e.g.,rlp/runs/<trace_id>/agents/<agent>/<uuid>), and the agent publishes its response on that topic. -
Request (
CT_EXECUTOR_AGENT_REQUEST, typeintent.executor.agent.request.v1) body:{ "type": "intent.executor.agent.request.v1", "payload": { "node": { /* serialized Node from the DSL */ }, "inputs": { /* NodeInputs */ }, "trace_id": "trace:...", "opening_id": "opening:...", "attempt": 1, "reply_topic": "rlp/runs/<trace>/agents/<agent>/<uuid>" }, "meta": null }RMP headers carry
trace_id,msg_id, and TTL (default 30s). The executor subscribes toreply_topicbefore publishing the request. -
Response (
CT_EXECUTOR_AGENT_RESPONSE, typetoolresult.executor.agent.response.v1) body:{ "type": "toolresult.executor.agent.response.v1", "payload": { "Completed": { "ports": { "out": [ { "schema_id": 0x00D1, "type": "artifact.draft.email.v1", "value": { ... } } ] } } } // meta is currently omitted }or
{ "type": "toolresult.executor.agent.response.v1", "payload": { "Failed": { "retryable": false, "reason": "timeout" } } } -
Agents (WASM or shim) consume the request body, execute their work, and publish the response on the per-request
reply_topic. The executor rebuildsNodeOutputsfrom the typed port data.
10. Run event stream (MVP)
After RunAccepted, the daemon publishes a single unified stream of run events:
- Topic:
rlp/runs/<trace_id>/events. - Schema:
CT_RUN_EVENT. - Payload shape:
{
"kind": "log|status|plan|trace|artifact|progress",
"level": "info|warn|error"?,
"message": "...",
"meta": {
"ts_ms": <u64>,
"run_id": "...",
"node_id": "..."?,
"span_id": "..."?
}
}
- Ordering: FIFO per publisher per topic; no sequence number in MVP.
- Live-only: no backfill on subscribe; the daemon persists durable history in the KB.
11. Topic namespaces & ACLs
rlp/ctrl— control-plane submit/ack.rlp/runs/<trace_id>/events— unified per-run event stream.rlp/sys/*— reserved for system/admin (drops, metrics, stats). Drop notices useCT_BUS_DROP_NOTICEonrlp/sys/drops.action.decision(schemaCT_ACTION_DECISION) may only be published byui|tuipublisher kinds; the bus rejects other publishers.
12. Socket & transport (MVP)
- Single Unix domain socket is used for both bus and control.
- Discovery precedence:
- If
runtime.socket_pathis non-empty, use it (short‑circuit; if unreachable → error immediately). - Else if
runtime.sockets_diris set,${runtime.sockets_dir}/rmp.sock. - Else
~/.runloop/sock/rmp.sock. - Else
/run/runloop/rmp.sock.
- If