ABP — Cosmix Wire Protocol Specification
Every app is a service. Every service speaks ABP. Every node is reachable. One protocol, one language, one wire format — from Unix socket to mesh.
1. What ABP Is
On the Amiga, ARexx was built into the OS. Every serious application exposed an ARexx port — a named endpoint that accepted commands and returned results. A three-line script could tell a paint program to render an image, a word processor to insert the filename, and a file manager to move it to a folder. No APIs to learn, no SDKs to install — just send commands to named ports in a common language.
ABP (Agent Bus Protocol) recreates this for the cosmix desktop, extended to span multiple machines over WireGuard. Every application — desktop renderer, mail server, file sync daemon, or AI inference pipeline — registers as a service on its local broker. Mix is the scripting language. Rust is the engine. ABP is the wire format everywhere — broker WebSockets, mesh peering, and log files.
| Amiga / ARexx | ABP / Cosmix |
|---|---|
ADDRESS 'APP' 'COMMAND' | send "maild" "account.list" (Mix) |
| ARexx port name | ABP service name: maild on node alpha |
rx script.rexx | mix script.mix |
| REXX (universal glue) | Mix (ARexx-inspired, pure Rust) |
| Single machine IPC | Multi-node mesh (WireGuard + ABP) |
Why Mix
- ARexx reborn — Mix is an ARexx-inspired language with native ABP keywords
(
send,address,emit). A three-line Mix script orchestrates mesh services the same way a three-line ARexx script orchestrated Amiga apps. - Pure Rust — the interpreter (
cosmix-lib-mix) compiles into every cosmix binary. No external runtime, no FFI, no subprocess. - Hot-reloadable — edit a script, run it again. No compilation step.
- 115 builtins — JSON, regex, TOML, HTTP, crypto, filesystem, process management. Enough to build real tools without reaching for Rust.
- Shell-capable — Mix is the daily-driver shell (
/opt/cosmix/bin/mix), not just a scripting language. Interactive REPL, job control, PATH search.
Why Rust (everything else)
- One language for everything — desktop renderer (cosmix-disp-skia), mail server (cosmix-maild), node daemon (cosmix-noded), file sync (cosmix-syncd), AI inference (cosmix-agentd). Single-binary deployments everywhere.
- Memory safety guaranteed — the compiler catches the bugs that crash C programs and corrupt data in Go programs.
- Performance — zero-cost abstractions, no garbage collector, no runtime. A Rust ABP parser is as fast as a hand-written C parser.
- Type safety end-to-end — the same
AmpMessagestruct serialises to a WebSocket frame, a broker route, and a log file.
2. Design Principle: Three-Reader Format
Every ABP message must be simultaneously useful to three readers:
- Machines — deterministic header parsing for routing, dispatch, and filtering. A router reads
to:,from:,command:and forwards. No understanding required. - Humans —
cat,grep, render in any markdown viewer. A developer debugging at 2am reads the message and knows what happened. - AI agents — natural language comprehension without schema definitions. An agent reads the full message — headers and body — and reasons about it as text.
This is not a nice-to-have. It is the core constraint that drives every format decision in ABP.
Why this matters
Traditional protocols serve one reader well and force the others through translation layers:
| Protocol | Machine | Human | AI Agent |
|---|---|---|---|
| protobuf/gRPC | Native | Opaque binary | Needs SDK wrapper + schema docs |
| JSON-RPC (MCP) | Native | Readable but verbose | Needs tool definitions + JSON schemas |
| Length-prefixed JSON | Native | Need tooling to frame | Need framing code + schema |
| MQTT | Native | Topic strings readable, payloads often binary | Needs topic documentation |
| ABP | Headers route deterministically | cat message.amp.md | Reads natively — it's markdown |
An AI agent consuming an ABP event stream pays ~60% fewer tokens than the equivalent JSON-RPC representation, while gaining MORE context, not less. The markdown body is the format LLMs were trained on billions of tokens of. An agent doesn't need a tool definition to understand:
---
amp: 1
type: event
from: maild.alpha.amp
command: email.received
---
# New email received
From: **alice@example.com**
Subject: Invoice Q2-2026
Mailbox: Inbox
Size: 4.2 KB
It reads it, understands it, and can generate a response in the same format.
The boundary rule
Headers route. Bodies reason. A dumb router must never need to parse the body. An agent should never need to understand headers to reason about content. The same message works for a stateless forwarder (parse three headers, dispatch) AND a reasoning agent (read everything, think about it).
AI agents as mesh nodes
An AI agent connected to the mesh via WebSocket is a first-class participant:
- No tool definition maintenance — when a new port appears on the mesh, the agent discovers it through HELP commands (which return ABP messages). No JSON schema to update.
- Self-describing commands —
command: search,args: {"query": "invoices"},from: maild.beta.amptells the agent what this does. - Multi-agent coordination becomes conversation — two agents on different nodes exchanging ABP messages are passing structured text to each other. Headers handle routing; bodies handle reasoning.
3. ABP Everywhere: One Protocol, All Transports
v0.4's defining change: ABP is no longer reserved for mesh communication. Every byte that crosses a cosmix boundary — local Unix socket, mesh WebSocket, log file — uses ABP framing. There is no "internal format" vs "wire format" distinction. There is only ABP.
Why not length-prefixed JSON for local sockets?
The v0.3 spec assumed local IPC would use a simpler binary-framed JSON format, with ABP reserved for mesh traffic. This created two parsers, two serialisers, two test suites, two mental models, and a translation layer at the mesh boundary. v0.4 eliminates this:
| Concern | Length-prefixed JSON | ABP --- framing |
|---|---|---|
| Framing | Read 4 bytes → decode length → read N bytes (two-phase) | Scan for ---\n (single-phase, streamable) |
| Debugging | Need tooling to read (xxd, custom deserialiser) | cat, grep, tail -f work directly |
| Error recovery | Lost sync = lost connection (can't resync without length) | Scan forward to next ---\n and resume |
| Streaming | Must buffer entire message before parsing | Parse headers as they arrive, stream body |
| Code reuse | Separate parser from mesh parser | Same amp_parse() everywhere |
| AI readability | Structured but needs framing context | Native — it's text |
The ---\n delimiter is 4 bytes, same as a 32-bit length prefix. But it's
self-describing (you can see it in a hex dump), resyncable on error, and
requires no byte-order convention.
Body delimiter safety. Since ABP bodies may contain markdown, and markdown
horizontal rules (--- on their own line) collide with the ---\n delimiter,
ABP uses a two-line end-of-message marker: ---\nEOM\n. A --- line in the
body is safe — only the exact sequence ---\n followed by EOM\n terminates
a message. See §5.2 for details.
The transport matrix
ABP frames are transport-agnostic. The same parser handles them whether they ride a WebSocket, a Unix socket, a log file, or a future transport. The matrix below lists transports currently in use; §8 covers candidates under evaluation. New transports may be added without changing the wire format.
Current transports (working):
| Transport | Where | Format | Notes |
|---|---|---|---|
| WebSocket | Service ↔ broker (local) | ABP frames in WS text messages | ws://192.0.2.x:4200/ws |
| WebSocket | Node ↔ node (mesh) | ABP frames in WS text messages | Over WireGuard tunnel |
| WebSocket | Browser ↔ webd | ABP frames in WS text messages | Authenticated per-session |
| Log file | Debug/audit | ABP messages concatenated | cat and grep just work |
| CLI pipe | mix stdin/stdout | ABP | Single request-response |
One parser. One serialiser. One test suite. One mental model. Multiple transports, selected by workload (§8.1).
4. The ABP Address
ABP uses DNS-style addressing. Addresses read left-to-right from
most-specific to least-specific, with an optional @<mesh-fqdn> suffix
when the target is on a remote mesh.
4.1 Local form (in-mesh)
Canonical grammar (the .amp suffix is optional on the two- and
three-label forms; it is required for the node-only single-label form):
local-address = sub "." service "." node [ ".amp" ]
| service "." node [ ".amp" ]
| node ".amp"
Examples:
imap.maild.alpha.amp → imap sub-protocol of maild on alpha
editor.disp-skia.alpha → editor window of disp-skia on alpha (.amp implied)
maild.beta.amp → maild service on beta
webd.alpha → webd service on alpha (.amp implied)
alpha.amp → the alpha broker (noded), implicit service
Definitions:
.ampis the mesh-local TLD. Routers MUST accept it as optional when the address has at least two labels (the form is unambiguous); routers MUST require it explicitly on the single-label node form (alpha.ampvs. bare service shorthandalpha).<node>is the mesh-local node name (kernel hostname or alias). Node names live in a single flat namespace per mesh.<service>is the registered broker name. It may differ from the implementing binary (cosmix-disp-skiaregisters asdisp-skia).<sub>is an opaque-to-the-broker sub-protocol or instance label. The broker routes by<service>.<node>; the destination service alone interprets<sub>to demultiplex internal endpoints (e.g.maildtreatsimapas the IMAP sub-protocol,disp-skiatreatseditoras a window/instance ID).- Local shorthand: a bare
<service>(no dot, no.amp) is not a parseable ABP address; it is a registry shortcut callers hand directly to the local broker, which resolves it to a local service connection.
Label syntax (all of <sub>, <service>, <node>): 1..=63 ASCII
characters from [a-z0-9-], neither starting nor ending with -. The
total address length MUST NOT exceed 253 characters including any
@<mesh-fqdn> suffix. Routers MUST reject addresses with more than
three left-side labels (silent-pad on splitn(3) is a known parser bug
class; the parser explicitly rejects a.b.c.d.amp).
4.2 Cross-mesh form
When a target lives on a different cosmix mesh, the address uses an
explicit @<mesh-fqdn> suffix:
cross-mesh-address = local-address "@" mesh-fqdn
The <mesh-fqdn> is a strict IDNA-canonical FQDN (lowercase ASCII
labels, at least one ., no trailing dot, no xn--* punycode pending
homograph review). Examples:
maild.beta.amp@example.org → maild on beta on the example.org mesh
disp-skia.alpha@example.net → disp-skia on alpha on a remote mesh
The @ is a syntactic boundary, not a delimiter: there MUST be exactly
one @, the left side MUST be a valid local ABP address, and the right
side MUST be a valid mesh FQDN.
Routing status (this revision): the parser MUST accept cross-mesh
addresses and the router MUST refuse them with error code 10 and the
message cross-mesh routing not implemented. This is a reserved form,
not a working transport — federation transport (envelope headers
target-mesh: / source-mesh:, broker-derived only, never
caller-supplied) and the SPEC 10 remote-service identity model land
together in a later revision. The parser-reserved / router-refused
split exists so that the wire grammar is stable now while the routing
plane evolves. See _decisions/2026-05-15-federated-amp-addressing.md
for the prior-art survey and the path to lifting the router refusal.
4.3 Deprecated and removed forms
| Form | Status | Migration |
|---|---|---|
<port>.<app>.<node>.amp w/ cosmix middle | Removed | Drop the cosmix label — <sub>.<service>.<node>.amp is the canonical 3-label form. The application namespace is the mesh identity now (see _decisions/2026-05-15-federated-amp-addressing.md), not a per-address label. |
<service>@<node> short-form | Migrated | Write <service>.<node> (which auto-implies .amp). The @ is reserved exclusively for the cross-mesh form in §4.2. |
4.4 Non-normative — DNS/SRV projection
The .amp TLD is cosmix-internal: no public registry, no DNS
delegation. Operators MAY project the .amp namespace onto an
internal-facing resolver so that legacy DNS tooling (getent,
drill, kdig) can resolve mesh addresses. The recommended shape
mirrors the address grammar:
<node>.amp→A/AAAArecord for the node's mesh-local IP<service>.<node>.amp→SRVrecord at_<service>._tcp.<node>.amppointing at the node, with a port that the ABP broker is listening on<sub>.<service>.<node>.amp→ resolved by the destination service itself (the broker does not project<sub>into DNS)
This projection is non-normative: an ABP-aware client SHOULD bypass DNS entirely and use the broker's service registry directly. The DNS projection exists for diagnostic tools and for cosmix-unaware clients that genuinely need name-to-address resolution.
5. The ABP Message
ABP messages use markdown frontmatter as the wire format — --- delimited
headers with an optional freeform body, terminated by ---\nEOM\n. The
encoding is UTF-8. Every ABP message is stored as a .amp.md file.
5.1 Grammar
message = "---\n" headers "---\n" body "---\n" "EOM\n"
headers = *(key ": " value "\n")
body = *UTF-8 (may be empty)
Every message begins with ---\n, the header block ends with ---\n, the
body continues until ---\nEOM\n (on a stream) or until EOF (in a file or
single-message context where ---\nEOM\n may be omitted).
Implementation status (audited 2026-06-05). The reference serializer
AmpMessage::to_wire()(cosmix-lib-amp/src/amp.rs) does NOT emit the---\nEOM\nterminator — it produces---\nheaders---\nbody\n. The live transport is WebSocket, where one frame carries exactly one message (the implicit-EOF single-message context above), so the terminator is unnecessary. The---\nEOM\nterminator and the multi-message streaming parser (§5.1, §6.3) remain specified-but-unimplemented for frame-unaware concatenated streams (e.g. a Unix-socket log of many messages). Consumers must not expect an EOM marker on the current WebSocket wire; resync-on-EOMis a future streaming-transport feature.
Why ---\nEOM\n and not just ---\n? ABP bodies are often markdown.
Markdown horizontal rules (--- on their own line) are common in real content.
A single ---\n delimiter would collide with any markdown body containing a
horizontal rule. The two-line sequence ---\nEOM\n is unambiguous — a ---
line in the body is safe; only --- immediately followed by EOM on the
next line terminates the message.
The separator is exactly : (colon + space). The grammar
production headers = *(key ": " value "\n") literally includes the
space. This matters at the edge cases:
| Line | Interpretation |
|---|---|
key: value\n | Canonical: key=key, value=value |
key: \n | Empty value: key=key, value="" (note trailing space) |
key:\n | Malformed — missing separator space; no value terminator |
key:value\n | Malformed — missing separator space |
key: value\n | Malformed — leading whitespace on header line |
key with space: value\n | Malformed — key contains whitespace |
Malformed lines are not valid ABP headers. Parsers SHOULD skip or
reject them per §6.4 (strict mode rejects; lenient mode records in
skipped_lines). Producers MUST emit key: \n (with trailing space)
for empty-value headers, never key:\n.
Keys. The grammar key is a non-empty sequence of visible UTF-8
characters excluding : and whitespace. Letters, digits, -, _,
and . are common; other punctuation is permitted but discouraged
for interoperability. Keys are case-sensitive.
5.2 Stream Framing
On a persistent connection (WebSocket, Unix socket), messages are concatenated:
---\n headers ---\n body ---\nEOM\n ---\n headers ---\n body ---\nEOM\n ...
The parser state machine:
- Read until
---\n→ start of a new message - Read lines until the next
---\n→ these are headers - Enter body mode. Read lines until
---\nappears: a. Peek at the next line — if it isEOM, yield the complete message b. Otherwise, the---is body content (e.g., a markdown horizontal rule); continue reading body - After yielding, return to step 1
Empty message (heartbeat/ACK) on the wire:
---\n---\n---\nEOM\n
That is: opening ---, empty header block closed by ---, empty body closed
by ---\nEOM\n. Three --- lines plus EOM.
Single-message context. When parsing a single message from a file or a
WebSocket text frame, the trailing ---\nEOM\n may be absent. The parser
treats EOF as an implicit end-of-message. The ---\nEOM\n terminator is
required only on persistent streams where multiple messages are concatenated.
Resync on error: If the parser loses state (partial read, corrupted bytes),
scan forward for \nEOM\n and resume at the next ---\n after it. This is
impossible with length-prefixed framing — a corrupted length field means every
subsequent message is misaligned.
5.3 The Four Shapes
One format, one parser, four message shapes:
| Shape | Description | Use case |
|---|---|---|
| Full message | Headers + markdown/text body | Events, rich responses, errors with context |
| Command | Headers only (including args:), no body | Requests, acks, simple responses |
| Data | Minimal json: header, no body needed | High-throughput streams, structured payloads |
| Empty | No headers, no body (---\n---\n) | Heartbeat, ACK, NOP, stream separator |
All four are delimited by --- and parsed identically.
5.4 Format Examples
Shape 1 — Full message (headers + body):
---
amp: 1
type: event
id: 0192b3a4-7c8d-0123-4567-890abcdef012
from: maild.alpha.amp
command: posted
---
# Status posted
Content: **Hello from the mesh!**
Visibility: public
URL: `https://mastodon.social/@user/123456`
Shape 2 — Command (headers only):
---
amp: 1
type: request
id: 0192b3a4-5e6f-7890-abcd-ef1234567890
from: mix.alpha.amp
to: maild.alpha.amp
command: status
ttl: 30
---
Shape 3 — Command with args:
---
amp: 1
type: request
id: 0192b3a4-5e6f-7890-abcd-ef1234567891
from: mix.alpha.amp
to: maild.alpha.amp
command: maild.email.query
args: {"filter": {"text": "invoice"}, "limit": 10}
ttl: 30
---
Shape 4 — Data (minimal envelope):
---
json: {"level": 0.72, "peak": 0.91, "channel": "left"}
---
Shape 5 — Response with structured body:
---
amp: 1
type: response
reply-to: 0192b3a4-5e6f-7890-abcd-ef1234567890
from: maild.alpha.amp
command: status
---
{"unread": 3, "total": 1247, "folders": ["inbox", "sent", "drafts"]}
Shape 6 — Error response:
---
amp: 1
type: response
reply-to: 0192b3a4-5e6f-7890-abcd-ef1234567890
from: maild.alpha.amp
command: maild.email.query
rc: 10
error: Account not found
---
Empty message (heartbeat/ACK):
---
---
---
EOM
5.5 Header Fields
| Field | Required | Description |
|---|---|---|
amp | yes* | Protocol version (always 1) |
type | yes* | request, response, event, stream |
id | yes* | UUID v7 (time-ordered) |
from | yes* | Source ABP port address |
to | no | Target ABP port address (omitted for events/broadcasts) |
command | yes* | The action to perform or that was performed |
args | no | Command arguments as inline JSON: {"key": "value"} |
json | no | Self-contained data payload as inline JSON |
reply-to | no | Message ID this responds to (responses only) |
rc | no | Return code: 0=success, 5=warning, 10=error, 20=failure |
ttl | no | Request timeout in seconds (default 30). The broker drops the request if the target service does not respond within TTL. Does not apply to events or responses. |
error | no | Error description string (when rc > 0) |
timestamp | no | ISO 8601 with microseconds |
* Required for full messages and commands. Data-only messages (json: shape)
may omit routing headers when the transport already provides context — i.e.,
on an established WebSocket session where from: is implied by the connection
identity and to: is implied by the subscription or session context. The
broker does not route json:-only messages; they are point-to-point on an
existing connection. If a data message needs routing, add from: and to:
headers.
5.5.1 Header Value Types
All header values are UTF-8 strings. A header value whose first
non-whitespace character is [ or { MAY be interpreted as JSON
by a consumer that expects structure. Parsers that do not need to
decode JSON receive the raw string and pass it through.
This rule applies to every header, not only args: and json:.
Reserved names like args and json carry a documented JSON schema
in §5.5 and §5.4; other headers using JSON values are self-describing
to their consumers.
| First non-whitespace char | Value is | Example |
|---|---|---|
[ | JSON array | draws_from: ["A", "B", "C"] |
{ | JSON object | args: {"q": "hello"} |
| anything else | Plain string | title: Declaration of Digital Independence |
This gives ABP a single escape hatch to structured values without
adding a type system to the wire format. The consumer decides when
to decode — an efficient broker routing on to: and command: never
needs to JSON-parse any other header; a service-level handler that
cares about a structured field invokes json_parse on exactly that
field's value.
No false positives. A plain-string value will never legitimately
begin with [ or { — there is no prose context in which these are
the natural first characters. A title, name, path, ID, address,
timestamp, or code all begin with letters, digits, or permitted
punctuation but not brackets. This makes the [/{ heuristic safe
without disambiguation markers.
Multi-line JSON is not permitted in headers. A header is one line
terminated by \n. If a structured payload is large enough to want
multi-line formatting, use the body with json:-shape framing (§5.4
Shape 4) instead.
The [/{ rule applies to headers only, not bodies. The
header/body boundary is the second ---\n (§5.1). Everything before
it is header lines; everything after it (up to ---\nEOM\n or EOF)
is body. A body is UTF-8 freeform regardless of its first character
— Shape 5 in §5.4 shows a response whose body is a raw JSON object
{"unread": 3, ...}, but this is body content, not a header value.
Parsers treat the body as an opaque string; structured interpretation
of a body is the consumer's choice based on the command:/type:
headers, not the body's first byte. There is no ambiguity between
header-JSON and body-JSON because they live on different sides of
the ---\n separator.
5.6 Return Codes
Following the ARexx convention:
| Code | Meaning | Example |
|---|---|---|
| 0 | Success | Command executed normally |
| 5 | Warning | Partial result, non-fatal issue |
| 10 | Error | Command failed but port is fine |
| 20 | Failure | Severe error, port may be degraded |
Return codes appear in the rc: header of response messages. Absence of rc: implies success (rc=0).
5.7 Standard Commands
Every ABP service SHOULD support these commands:
| Command | Description | Returns |
|---|---|---|
HELP | List available commands | Command names + descriptions |
INFO | App name, version, capabilities | App metadata |
ACTIVATE | Bring app window to front | rc: 0 or 10 |
OPEN | Open a file/URI | rc: 0 or 10 |
SAVE | Save current document | rc: 0 or 10 |
SAVEAS | Save current document to path | rc: 0 or 10 |
5.8 Headers in Doc and Spec Files
Markdown files under _doc/, _spec/, _plan/,
_memory/, and _journal/ carry ABP headers —
structurally identical to wire-message headers. This lets a single
parser read both wire traffic and repository artifacts; no YAML
parser is required anywhere in the stack.
The rules of §5.1 (Grammar) and §5.5.1 (Header Value Types) apply with two accommodations for the filesystem context:
- No
---\nEOM\nterminator. Doc files are ABP messages in single-message context (see §5.2). The header block closes on---\n; the body continues to EOF. This matches standard markdown frontmatter framing and is readable by any markdown tool. - Domain-specific header vocabulary. Doc/spec headers use
fields appropriate to their role (
title,chapter,status,ratified,promotes_to,draws_from, etc.) rather than the wire-routing fields (amp,type,from,to,command,ttl). Both are valid ABP header sets; the vocabulary differs by purpose.
All other rules hold: flat key: value pairs one per line, no
nesting, no YAML list syntax, values starting with [ or { MAY be
JSON.
List-valued fields use JSON array syntax:
draws_from: ["Local-first software (Kleppmann et al., 2019)", "A Declaration of the Independence of Cyberspace (Barlow, 1996)"]
Structured list-of-object fields use JSON object arrays:
amendments: [{"date": "2026-04-20", "scope": "tenet7", "summary": "rules-are-calibration"}]
Doc/spec field vocabulary conventions (non-normative; each folder establishes its own):
| Folder | Common fields |
|---|---|
_spec/ | title, chapter, version, status, date, supersedes?, amends?, companion? |
_doc/ | title, date, status, next_review?, promotes_to?, draws_from?, external_precedents? |
_plan/ | title, date, status, draws_from? |
_memory/ | (CMM-generated; single-section reports with tier + generator) |
_journal/ | (no headers required; dated title is the index) |
Grandfathering. Doc files using YAML list syntax (- item on
indented lines) are valid but non-canonical. When a file is
modified, flatten its headers to ABP-shape in the same commit.
Bulk migration is mechanical and may be performed as a single
autowork/amp-header-migration branch commit (Tier 2 per the
constitution).
Why this matters. The three-reader principle (§2) extends to
repository artifacts. A spec, a proposal, a journal entry, and an
ABP wire message all parse with the same 15-line parser. An indexer
that understands ABP understands docs. A Mix script reading a law
uses the same split("\n") + split(": ", 2) it would use on an
ABP message. One format, one parser, one mental model — across the
entire stack.
| CLOSE | Close current document/tab | rc: 0 or 10 |
| QUIT | Graceful shutdown | rc: 0 |
Services extend this vocabulary with domain-specific, service-prefixed
commands (e.g., maild.account.list, maild.email.query for maild;
syncd.share.list, syncd.peer.add for syncd). See
2026-03-29-02-bus-command-vocabulary.md for naming conventions and the
Mix-shorthand rule (send "maild" email.query → wire-form
maild.email.query).
6. Parsing
Headers are flat key: value strings — no YAML parser needed. Two keys (args and json) carry inline JSON, decoded with serde_json. The parser is identical for all shapes and all transports.
6.1 Rust Reference Parser
use std::collections::HashMap;
pub struct AmpMessage<'a> {
pub headers: HashMap<&'a str, &'a str>,
pub body: &'a str,
}
pub fn amp_parse(raw: &str) -> AmpMessage<'_> {
let content = raw.strip_prefix("---\n").unwrap_or(raw);
let (fm, body) = content.split_once("\n---\n").unwrap_or((content, ""));
let mut headers = HashMap::new();
for line in fm.lines() {
if let Some((k, v)) = line.split_once(": ") {
headers.insert(k.trim(), v.trim());
}
}
AmpMessage { headers, body }
}
pub fn amp_serialize(headers: &[(&str, &str)], body: &str) -> String {
let mut out = String::from("---\n");
for (k, v) in headers {
out.push_str(k);
out.push_str(": ");
out.push_str(v);
out.push('\n');
}
out.push_str("---\n");
if !body.is_empty() {
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
}
out.push_str("---\nEOM\n");
out
}
6.2 Mix ABP Keywords
Mix scripts don't parse ABP manually — the interpreter has native ABP keywords:
-- Send a command to a service via the local broker
send "maild" "account.list"
-- Address a service for multiple commands
address "display"
send "ui.window" id="main" title="Hello" body="# Welcome"
send "ui.style" target="main" bg="#1a1a2e"
-- Emit an event (publish to any subscribers)
emit "status" command="updated" body="System healthy"
Under the hood, send "maild" "account.list" generates:
---
command: maild.account.list
to: maild
from: script
---
The broker routes the message, and the response arrives as the return value of send.
6.3 Stream Parser (Rust, async)
For persistent connections, a streaming parser that yields messages as they
arrive. The key invariant: a --- line in the body is only an end-of-message
marker when the next line is EOM.
use tokio::io::{AsyncBufReadExt, BufReader};
pub async fn amp_stream<R: tokio::io::AsyncRead + Unpin>(
reader: R,
) -> impl futures::Stream<Item = String> {
let mut lines = BufReader::new(reader).lines();
async_stream::stream! {
let mut buf = String::new();
let mut in_message = false;
let mut in_body = false;
let mut pending_sep = false; // saw `---` in body, waiting for next line
while let Ok(Some(line)) = lines.next_line().await {
if pending_sep {
pending_sep = false;
if line == "EOM" {
// End of message — yield and reset
yield buf.clone();
in_message = false;
in_body = false;
buf.clear();
continue;
}
// The `---` was body content (e.g. markdown horizontal rule)
buf.push_str("---\n");
buf.push_str(&line);
buf.push('\n');
continue;
}
if line == "---" {
if !in_message {
in_message = true;
in_body = false;
buf.clear();
buf.push_str("---\n");
} else if !in_body {
in_body = true;
buf.push_str("---\n");
} else {
// In body — might be end-of-message or body content
pending_sep = true;
}
} else if in_message {
buf.push_str(&line);
buf.push('\n');
}
}
// Yield trailing message on EOF (single-message context)
if in_message && !buf.is_empty() {
yield buf;
}
}
}
6.4 Parser Robustness
A compliant ABP header parser SHOULD extract key:value lines
matching ^key: value\n and MAY skip lines that do not match this
pattern — including YAML list items, indented continuations,
comments, blank lines, and any other non-compliant content. This
permits best-effort reading of legacy or external markdown whose
frontmatter uses YAML rather than ABP key:value syntax, without
requiring a YAML parser in the dependency graph.
Parsers that skip non-matching lines SHOULD report what was skipped to the caller, so that silent data loss is impossible. A suggested structured result:
pub struct ParseResult<'a> {
pub headers: BTreeMap<&'a str, &'a str>,
pub body: &'a str,
pub skipped_lines: Vec<(usize, &'a str)>, // (line_number, line_content)
pub json_parse_errors: Vec<(&'a str, &'a str)>, // (key, raw_value)
}
This separates format compliance from parser implementation, and lets consumers police strictness at their layer:
- Canonical ABP content (wire traffic per §5.1–§5.7; doc and
spec headers per §5.8): the consumer MUST assert
skipped_lines.is_empty() && json_parse_errors.is_empty(). Any non-empty case is a format violation and should be surfaced. - Legacy or external content (pre-migration cosmix docs; third-party markdown with YAML frontmatter): the consumer MAY accept skipped lines but SHOULD log them, so any data the heuristic cannot recover is visible to the operator.
Three properties make this pattern preferable to a mode flag:
- No silent data loss. Skipped lines are always recoverable by the caller. A YAML list that vanishes during parsing is reported; an operator sees what was lost.
- No YAML parser dependency. The heuristic tolerates YAML-shaped
input without understanding YAML semantics. No crate (
serde_yml,yaml-rust2, or otherwise) enters the cosmix dependency graph. - JSON parse failures are separately visible. A header value
starting with
[or{that failsjson_parse(per §5.5.1) is treated as a string AND recorded injson_parse_errors— never silently misinterpreted.
Known limitations when reading YAML frontmatter via this heuristic:
| YAML construct | Outcome under robust ABP parse |
|---|---|
Indented list items ( - item) | Skipped; parent key has empty value |
Multi-line scalars (key: |\n line1) | Continuation skipped; value appears as | |
Nested objects (parent:\n child: val) | Children skipped; parent has empty value |
YAML flow (key: [A, B, C]) | JSON parse fails; recorded in json_parse_errors |
YAML anchors/references (&defaults, *defaults) | Partial match; semantically meaningless |
These limitations are deliberate. Fully recovering YAML semantics requires a YAML parser — exactly what cosmix avoids. Best-effort extraction with loud failure reporting is the chosen compromise.
Reference parser contract. The amp_parse function in §6.1 is
the minimal illustrative form; production parsers SHOULD return the
ParseResult shape above. Existing callers that want the simpler
AmpMessage can wrap: amp_parse_strict(raw) returns
Result<AmpMessage, ParseError> where ParseError::NonCompliant
carries the skipped lines; amp_parse_lenient(raw) returns
(AmpMessage, Vec<SkippedLine>) without error when non-compliant
content is present.
7. The Cosmix Stack
Every component in the cosmix stack speaks ABP natively. The per-node broker (cosmix-noded) is the local router — every service connects to its local broker via WebSocket and exchanges ABP messages through it; brokers peer with each other over WireGuard for cross-node traffic.
7.1 Component Map
| Component | Role | Language | ABP Integration |
|---|---|---|---|
| cosmix-lib-amp | ABP wire format library | Rust | Parse/serialize, BTreeMap headers + body |
| cosmix-noded | Per-node ABP broker + config + monitor + logger | Rust | Local ABP router, service registry, topic broker, mesh peer |
| cosmix-disp-skia | ABP Display Protocol renderer | Rust | Registers as display service; receives ui.* commands, renders native UI |
| cosmix-maild | JMAP + SMTP mail server | Rust | Mail operations as ABP commands |
| cosmix-webd | Web server + CMS API | Rust | ABP WebSocket gateway for browsers |
| cosmix-syncd | File sync (syncthing wrapper) | Rust | Sync operations as ABP commands |
| cosmix-mcp | MCP bridge for Claude Code | Rust | Bridges AI tool calls to ABP |
| cosmix-mix | Mix interpreter + shell | Rust | send/address/emit generate ABP |
| cosmix-agentd | AI agent loop daemon | Rust | LLM tool use over ABP |
7.2 Desktop Apps (archived ABP Display Protocol)
The ui.* rendering lane described by the example below is historical. It was
retired by _decisions/2026-07-18-amp-as-control-plane.md; its source remains
at cos tag amp-display-archive. Current CosMix Desktop applications render
natively through Bevy/wgpu and CTK and expose semantic ABP app-control ports.
In the archived lane, user interactions flowed back as ABP events:
-- dopus.mix — dual-pane file manager
emit "display" ui.window id="dopus" title="Directory Opus" width=1200 height=800 body=<<MD
```splitpane id=main direction=horizontal split=0.4
## Left Pane
| Name | Size |
|------|------|
| Documents/ | — |
| Pictures/ | — |
---
## Right Pane
| Name | Size |
|------|------|
| report.pdf | 4.2 KB |
MD
on ui.event -- handle file selection, navigation, etc. done
The app owns all state. The display service is stateless — it draws what it
receives and reports interactions. The full protocol was specified in
`2026-04-07-05-amp-display-protocol.md` (retired 2026-08-16 — `ui.*` left ABP
at the control-plane pivot; the live display stack is chapter 16).
### 7.3 Mix Scripting (the ARexx experience)
```mix
-- mail-summary.mix
$status = send "maild" "account.list"
$accounts = from_json($status)
each $accounts as $acct do
say $acct.email .. ": " .. $acct.unread .. " unread"
done
Under the hood, send "maild" "account.list" generates:
---
command: maild.account.list
to: maild
from: script
---
The broker routes the message to the maild service, which responds:
---
command: maild.account.list
rc: 0
---
[{"email": "mark@example.com", "unread": 3}]
7.4 Web API (cosmix-webd)
cosmix-webd serves HTTP and WebSocket endpoints, bridging browser clients to the ABP mesh:
Browser → WebSocket → cosmix-webd → local broker → target service
The same ABP commands accessible from Mix scripts and desktop apps are available to authenticated browser sessions.
7.5 Mail (cosmix-maild)
cosmix-maild is a complete JMAP (RFC 8620/8621) + SMTP mail server in a single Rust binary. It registers on the local broker and exposes mail operations as ABP commands:
---
command: maild.email.query
to: maild
args: {"filter": {"text": "invoice"}, "limit": 10}
---
7.6 Mesh (cosmix-noded + WireGuard)
cosmix-noded peers with other nodes over WireGuard WebSocket connections, forwarding ABP messages between meshes:
Mix script → local broker (noded) → WireGuard WS → remote broker (noded) → remote service
The message is ABP the entire way. No format translation at any boundary.
8. Transport Layers
Transport is an implementation detail — callers address services, not transports. ABP frames are portable across transports; the same wire format rides a WebSocket, a Unix socket, a log file, or any future transport that can carry framed text. The choice of transport is made per workload: latency tolerance, message volume, payload kind, and trust-domain shape together select what carries the bytes underneath ABP.
Applications never see postcard. The postcard binary protocol exists only
at the compositor↔shell boundary for latency-critical paths (60-165Hz pointer
events, frame callbacks). All application-facing communication uses ABP. See
README.md §Protocol Boundary Table.
8.1 Workload classification
The transport selected for any given path falls into one of these workload classes. ABP rides whichever transport fits; the wire format does not change.
| Workload | Examples | Transport | Status |
|---|---|---|---|
| App↔app conversation | ui.window, file.list, noded.ping | WebSocket via broker | working |
| Hot-path Rust↔Rust | Pointer events, frame callbacks (≥60Hz) | postcard over Unix socket / TCP | planned (Phase B/C) |
| Bulk binary transfer | File sync blocks, blob replication, backups | postcard over TCP/WG; iroh-blobs as candidate | candidate evaluation pending — see §8.3 |
| Real-time media | Voice, video, screen share | WebRTC (str0m) | planned (calld) |
| Cross-mesh peer connect | Future: peers across WG domains | iroh-net as candidate (constitutional implications) | deferred — see §8.5 |
| Topic fan-out (single mesh) | world.* retained topics, event broadcasts | ABP topic broker via local broker | working |
| Topic fan-out (federation scale) | Future: gossip across many nodes | iroh-gossip as candidate | deferred |
The classification is workload-first, not protocol-first. Naming a transport in this table is an evaluation, not a commitment — candidates require a spike with measured benefit before they enter the working set.
8.2 ABP control transports (working)
The transports currently carrying ABP application traffic.
| Path | Latency | Status |
|---|---|---|
| Mix → broker WebSocket → service | ~0.5ms | Working |
| Service → broker → service (local) | ~0.5ms | Working |
| Broker → WireGuard WS → remote broker → service | ~2-5ms | Working |
| Browser → webd WS → broker → service | ~2ms est. | Design phase |
Local path (hot path for scripting):
Mix script → broker WS → ABP route → service WS → command handler → ABP response
Mesh path (cross-node):
Mix script → local broker → WireGuard WS → remote broker → remote service
Browser path (web access):
Browser → webd WS → ABP frame → broker → service → ABP response → webd WS → browser
8.3 Bulk binary transfer
Bulk binary movement (file sync blocks, blob replication, backups) is not a job for ABP frames in WebSocket text messages — neither the framing nor the relay-through-broker model is appropriate for multi-megabyte content.
Current plan: postcard-encoded chunks over raw TCP riding the WireGuard tunnel, with the native sync engine handling chunking, dedup, and resume manually.
Candidate under evaluation: iroh-blobs — content-addressed (BLAKE3) blob transfer with parallel chunking, resumable streams, and built-in dedup. Replaces large parts of what the native engine would otherwise reinvent. Evaluation criteria: throughput on the WG link, peak RSS during transfer, dependency footprint, and how cleanly its API composes with ABP-level orchestration (the metadata, not the bytes, still rides ABP).
The decision between custom postcard-over-TCP and iroh-blobs is open. A spike against a representative sync workload is the next concrete step. Companion design memo: _decisions/2026-04-27-amp-transport-layering.md.
8.4 Real-time media (WebRTC)
Real-time media streams use WebRTC data channels, negotiated via ABP signalling on the control plane:
| Path | Use case |
|---|---|
| Server ↔ browser | Audio playback (TTS), screenshots, file transfer |
| Browser ↔ browser | Voice chat, screen share (peer-to-peer via ICE) |
| Server ↔ server | Audio/video relay between nodes |
Signalling flow — WebRTC connections bootstrap over the existing ABP WebSocket:
- Browser sends ABP request:
command: webrtc-offerwith SDP in body - Server responds: SDP answer + ICE candidates in ABP response body
- WebRTC data channel opens — binary streams flow directly
- ABP control plane continues alongside on WebSocket
WebRTC is the right transport for media (jitter buffer, echo cancellation, RTP). It is not the right transport for non-real-time bulk binary — that's §8.3.
8.5 Trust domain and transport selection
Per Article III.2 of the constitution, the WireGuard /24 is the mesh trust domain — authentication happens at peering, not per-message. Transports listed above run inside this trust domain. Transport choice does not, by itself, alter the trust model.
iroh-net is the one candidate that touches this line. iroh's design assumes per-peer node-identity trust at the application layer rather than network-layer trust at the mesh. Two distinct decisions follow:
- Adopting
iroh-netas a transport inside WireGuard is a tech choice — no constitutional change. The trust domain remains the WG /24. - Adopting
iroh-netas the primary trust mechanism, allowing peers to connect across WG domains using only Ed25519 node IDs, would amend Article III.2. That is a separate constitutional question, deferred until cross-mesh peering becomes a real need.
These two should not be conflated. Naming iroh-net in §8.1 is an evaluation, not a constitutional move.
9. Service Discovery and Lifecycle
9.1 Service Registration
When a service starts, it connects to its local broker (cosmix-noded) via
WebSocket and sends a noded.register message. The service name is taken
from the message's from header (the requested service identity, which the
broker then binds to that connection), NOT a body field. The body is an optional RegisterProvenance
(build provenance — version / git_sha / build_time / pid / started_at /
binary; SPEC 02 §4.1); omitting it registers the name only.
---
command: noded.register
from: maild
body: {"version": "0.2.2", "git_sha": "2e6ee65d2381", "build_time": "2026-06-01T07:02:26Z", "pid": 4242}
---
The broker stamps registered_at, stores the merged ServiceInfo in its
registry, and adds the service to its routing table. All subsequent messages
addressed to that service name are forwarded over the WebSocket connection;
the stored provenance surfaces via noded.list / noded.info (§9.3, SPEC 02 §4.1).
9.2 Service Heartbeat
The broker sends periodic noded.ping messages. Services must respond
within the configured timeout or be deregistered:
---
command: noded.ping
---
Response:
---
command: noded.ping
rc: 0
---
9.3 Service Discovery by Scripts
The broker's service registry stores a ServiceInfo per registered name —
the build provenance (version / git_sha / build_time / pid / …) the citizen
supplies at noded.register, plus a broker-stamped registered_at. So
noded.list returns [ServiceInfo] objects (not bare names) and noded.info
exposes node identity + the broker's own build. The normative schema is
SPEC 02 §4.1; the shared type is cosmix-lib-amp::service_info.
-- List all registered services (with version/git_sha/build_time per service)
$services = send "noded" "noded.list"
for each $s in $services
say $s.name .. " " .. $s.version .. " (" .. $s.git_sha .. ")"
end
-- Send to a specific service
$result = send "maild" "account.list"
9.4 Mesh Service Discovery
When cosmix-noded peers with a remote node over WireGuard, it exchanges service lists. Remote services are addressable by node name:
-- Local service (local broker routes)
$status = send "maild" "account.list"
-- Remote service (broker routes via mesh — `.amp` implied)
$status = send "maild.beta" "account.list"
10. The Mesh
10.1 Nodes
| Node | WireGuard IP | Role | Services |
|---|---|---|---|
alpha | 192.0.2.5 | Desktop / dev | noded, display (cosmix-disp-skia), maild, mcp, agentd |
beta | 192.0.2.3 | Mail / web (LAN) | noded, maild, webd |
gamma | 192.0.2.4 | Mail / web (LAN) | noded, maild, webd |
delta | 192.0.2.9 | Public VPS | noded, maild, webd |
All nodes connected via WireGuard mesh. All services are Rust single binaries
managed by systemd. All mesh services bind to WireGuard IPs only (192.0.2.x),
never 0.0.0.0.
10.2 Node Tiers
| Tier | Connection | Trust | Examples |
|---|---|---|---|
| Mesh nodes | Always-on, WireGuard | Trusted (WG /24 is the trust domain) | alpha, beta, delta, gamma |
| Browser clients | Ephemeral, WebSocket via webd | Authenticated per-session | Any browser tab |
Mesh nodes run cosmix-noded and route traffic for browser clients. A browser connects to cosmix-webd which bridges to the local broker. The WireGuard /24 subnet is the trust domain — no per-message auth within the mesh.
10.3 Mesh Heartbeat
cosmix-noded exchanges heartbeat ABP messages between peered nodes:
---
command: noded.ping
from: noded
---
Response includes service list and capabilities for fleet discovery.
11. Security
11.1 Transport Security
| Transport | Security |
|---|---|
| Unix socket | File permissions (0700), user-namespace isolation |
| WireGuard mesh | Authenticated encryption (Curve25519 + ChaCha20-Poly1305) |
| Axum WebSocket | TLS + session authentication (axum-login + tower-sessions) |
11.2 Service ACLs
The WireGuard /24 subnet is the trust domain. All services within the mesh trust each other implicitly — the WG key exchange is the authentication. Per-service ACLs are a future extension for when the mesh grows beyond a single-operator deployment.
11.3 No Secrets in ABP
ABP messages are designed to be loggable and debuggable. Secrets (API keys, tokens, passwords) must NEVER appear in ABP headers or bodies. Use reference tokens or session IDs instead.
12. Design Rationale
12.1 Why --- Frontmatter, Not JSON
| JSON wire format | ABP frontmatter |
|---|---|
{"type":"request","from":"...","command":"search","args":{"q":"hello"}} | ---\ntype: request\nfrom: ...\ncommand: search\nargs: {"q":"hello"}\n---\n |
| 87 bytes, one reader (machine) | 92 bytes, three readers |
| Nested structure requires full parse to route | Flat headers — route on first 3 lines |
| Pretty-printing adds 300%+ overhead | Already human-readable |
| Body must be escaped/encoded | Body is freeform markdown |
The 5-byte overhead per message buys human readability, AI comprehension, and markdown bodies. For high-throughput data streams, the json: shape is nearly as compact as raw JSON with framing included.
12.2 Why Flat Headers, Not YAML
YAML parsing is complex, error-prone, and has well-documented
security issues (billion laughs, type coercion, anchors). ABP
headers look like YAML but are intentionally restricted to flat
key: value — no indentation, no nesting, no type coercion. A
correct ABP header parser is ~15 lines of code in any language.
The same rule applies to doc and spec headers (§5.8). Structured values use inline JSON (§5.5.1) rather than YAML indentation, so the stack never needs a YAML parser as a runtime or build-time dependency.
Terminology note. "Frontmatter" in this spec refers specifically
to the ----delimited framing convention borrowed from markdown.
The content inside that block — the key:value pairs themselves —
are ABP headers, not YAML frontmatter. The framing is shared with
markdown tooling; the value semantics are not. When discussing the
key:value pairs, use "headers"; when discussing the delimiter
convention, "frontmatter" is fine.
12.3 Why args as Inline JSON
Command arguments need structure (nested objects, arrays, typed values). Headers need flatness (one line per field). Inline JSON in the args: field gives both: the header line is flat, the value is structured. Both Rust (serde_json) and Mix (from_json/to_json builtins) parse JSON natively.
12.4 Why Not MCP/JSON-RPC
MCP (Model Context Protocol) is purpose-built for AI tool calling. ABP is purpose-built for app-to-app orchestration where AI agents are first-class participants but not the only participants. Key differences:
- MCP requires tool definitions (JSON schemas) upfront. ABP ports are self-describing via HELP.
- MCP messages are opaque to humans without tooling. ABP messages are
cat-able. - MCP is request-response only. ABP supports events, streams, and broadcasts.
- MCP has no addressing model. ABP has DNS-native mesh addressing.
ABP and MCP can coexist: a cosmix MCP server can bridge AI tool calls to ABP port commands.
13. What Exists Today (as of 2026-04-18)
13.1 Working
| Component | Status |
|---|---|
| cosmix-lib-amp | ABP wire format library (parse, serialize, BTreeMap headers) |
| cosmix-noded | Per-node ABP broker + config + monitor + logger — local ABP router and mesh peer |
| cosmix-disp-skia | Archived ABP Display Protocol renderer; source retained at cos tag amp-display-archive. Current desktop applications are native Bevy/wgpu + CTK. |
| cosmix-maild | JMAP + SMTP mail server with ABP commands |
| cosmix-mcp | MCP bridge for Claude Code |
| cosmix-mix | Mix interpreter (115 builtins) + shell + ABP handler |
| cosmix-lib-mesh | WireGuard mesh networking, WebSocket peer sync |
| cosmix-indexd | Semantic indexing + vector storage (candle + sqlite-vec) |
| Topic pub/sub | Broker-mediated retained-message topic extension (see 2026-04-10-03-bus-topic-pubsub.md) |
| Mix scripting | send/address/emit ABP keywords, on event handlers |
13.2 In Progress
| Component | Status |
|---|---|
| cosmix-syncd | Syncthing wrapper with ABP surface (design complete) |
| cosmix-agentd | AI agent loop daemon with tool registry |
| cosmix-dopus | Dual-pane file manager (Mix script, design complete) |
13.3 Planned
| Component | Status |
|---|---|
| cosmix-shell | Window management policy layer (Phase B) |
| cosmix-comp | Smithay-based Wayland compositor (Phase C) |
| cosmix-calld | WebRTC audio/video calling (parked) |
| cosmix-webd | Web server + CMS API (partial) |
14. Architecture Layers
ABP is the foundation layer — every cosmix component speaks it. The full
stack mapping (from Amiga ROM to Intuition-equivalent desktop) is defined
in README.md, which is the authoritative reference for how the layers
compose and the phasing roadmap for building them.
15. Technical Decisions Summary
| Decision | Choice | Rationale |
|---|---|---|
| Wire format | ABP (markdown frontmatter) everywhere | Three-reader principle; one parser for all transports |
| Desktop rendering | Native Bevy/wgpu applications sharing CTK | Semantic ABP app-control ports; ui.* renderer lane archived under _decisions/2026-07-18-amp-as-control-plane.md |
| IPC routing | Per-node broker (cosmix-noded) via WebSocket | Local router, service discovery, topic broker, mesh peer |
| Scripting | Mix (pure Rust, ARexx-inspired) | Native ABP keywords, 115 builtins, shell-capable |
| Web framework | Axum | Rust-native, tokio-based, tower middleware. Used by cosmix-webd for HTTP/WS. |
| Mail server | cosmix-maild | JMAP + SMTP, single binary, SQLite storage |
| Database | SQLite (rusqlite) | Node-local simplicity, no external DB server |
| Mesh transport | WebSocket over WireGuard | Authenticated encryption, /24 trust domain |
| Binary hot path | postcard (compositor boundary only) | 60-165Hz pointer events, not for apps |
| Message IDs | UUID v7 | Time-ordered, globally unique, sortable |
| Error codes | ARexx convention (0/5/10/20) | Simple, memorable, sufficient |
| Process management | systemd | Proven, universal on Linux |
| Async runtime | tokio | De facto Rust async standard |
| Allocator | mimalloc | All binaries |
| Containers | Incus / Proxmox | Never Docker |
| No Python deps | Rust daemons or Mix scripts | Keeps the stack single-language with no runtime deps |
Document created: 2026-03-09, rewritten 2026-04-18 Status: Protocol specification v0.6.0 draft — active development Supersedes: AMP v0.5.1 (2026-04), v0.4 (2026-03-09), v0.3 (2026-03-02) — released under the protocol's former AMP name