Cosmix Specification Suite v0.4.2
‹ 00b01b ›cosmix.dev ↗
01Foundation
Chapter 01

ABP — Cosmix Wire Protocol Specification

version 0.6.0status draftdated 2026-04-26layer Foundation

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 / ARexxABP / Cosmix
ADDRESS 'APP' 'COMMAND'send "maild" "account.list" (Mix)
ARexx port nameABP service name: maild on node alpha
rx script.rexxmix script.mix
REXX (universal glue)Mix (ARexx-inspired, pure Rust)
Single machine IPCMulti-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 AmpMessage struct 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:

  1. Machines — deterministic header parsing for routing, dispatch, and filtering. A router reads to:, from:, command: and forwards. No understanding required.
  2. Humanscat, grep, render in any markdown viewer. A developer debugging at 2am reads the message and knows what happened.
  3. 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:

ProtocolMachineHumanAI Agent
protobuf/gRPCNativeOpaque binaryNeeds SDK wrapper + schema docs
JSON-RPC (MCP)NativeReadable but verboseNeeds tool definitions + JSON schemas
Length-prefixed JSONNativeNeed tooling to frameNeed framing code + schema
MQTTNativeTopic strings readable, payloads often binaryNeeds topic documentation
ABPHeaders route deterministicallycat message.amp.mdReads 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 commandscommand: search, args: {"query": "invoices"}, from: maild.beta.amp tells 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:

ConcernLength-prefixed JSONABP --- framing
FramingRead 4 bytes → decode length → read N bytes (two-phase)Scan for ---\n (single-phase, streamable)
DebuggingNeed tooling to read (xxd, custom deserialiser)cat, grep, tail -f work directly
Error recoveryLost sync = lost connection (can't resync without length)Scan forward to next ---\n and resume
StreamingMust buffer entire message before parsingParse headers as they arrive, stream body
Code reuseSeparate parser from mesh parserSame amp_parse() everywhere
AI readabilityStructured but needs framing contextNative — 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):

TransportWhereFormatNotes
WebSocketService ↔ broker (local)ABP frames in WS text messagesws://192.0.2.x:4200/ws
WebSocketNode ↔ node (mesh)ABP frames in WS text messagesOver WireGuard tunnel
WebSocketBrowser ↔ webdABP frames in WS text messagesAuthenticated per-session
Log fileDebug/auditABP messages concatenatedcat and grep just work
CLI pipemix stdin/stdoutABPSingle 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:

  • .amp is 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.amp vs. bare service shorthand alpha).
  • <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-skia registers as disp-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. maild treats imap as the IMAP sub-protocol, disp-skia treats editor as 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

FormStatusMigration
<port>.<app>.<node>.amp w/ cosmix middleRemovedDrop 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-formMigratedWrite <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>.ampA / AAAA record for the node's mesh-local IP
  • <service>.<node>.ampSRV record at _<service>._tcp.<node>.amp pointing 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\n terminator — 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\n terminator 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-EOM is 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:

LineInterpretation
key: value\nCanonical: key=key, value=value
key: \nEmpty value: key=key, value="" (note trailing space)
key:\nMalformed — missing separator space; no value terminator
key:value\nMalformed — missing separator space
key: value\nMalformed — leading whitespace on header line
key with space: value\nMalformed — 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:

  1. Read until ---\n → start of a new message
  2. Read lines until the next ---\n → these are headers
  3. Enter body mode. Read lines until ---\n appears: a. Peek at the next line — if it is EOM, yield the complete message b. Otherwise, the --- is body content (e.g., a markdown horizontal rule); continue reading body
  4. 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:

ShapeDescriptionUse case
Full messageHeaders + markdown/text bodyEvents, rich responses, errors with context
CommandHeaders only (including args:), no bodyRequests, acks, simple responses
DataMinimal json: header, no body neededHigh-throughput streams, structured payloads
EmptyNo 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

FieldRequiredDescription
ampyes*Protocol version (always 1)
typeyes*request, response, event, stream
idyes*UUID v7 (time-ordered)
fromyes*Source ABP port address
tonoTarget ABP port address (omitted for events/broadcasts)
commandyes*The action to perform or that was performed
argsnoCommand arguments as inline JSON: {"key": "value"}
jsonnoSelf-contained data payload as inline JSON
reply-tonoMessage ID this responds to (responses only)
rcnoReturn code: 0=success, 5=warning, 10=error, 20=failure
ttlnoRequest 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.
errornoError description string (when rc > 0)
timestampnoISO 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 charValue isExample
[JSON arraydraws_from: ["A", "B", "C"]
{JSON objectargs: {"q": "hello"}
anything elsePlain stringtitle: 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:

CodeMeaningExample
0SuccessCommand executed normally
5WarningPartial result, non-fatal issue
10ErrorCommand failed but port is fine
20FailureSevere 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:

CommandDescriptionReturns
HELPList available commandsCommand names + descriptions
INFOApp name, version, capabilitiesApp metadata
ACTIVATEBring app window to frontrc: 0 or 10
OPENOpen a file/URIrc: 0 or 10
SAVESave current documentrc: 0 or 10
SAVEASSave current document to pathrc: 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:

  1. No ---\nEOM\n terminator. 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.
  2. 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):

FolderCommon 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:

  1. 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.
  2. 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.
  3. JSON parse failures are separately visible. A header value starting with [ or { that fails json_parse (per §5.5.1) is treated as a string AND recorded in json_parse_errors — never silently misinterpreted.

Known limitations when reading YAML frontmatter via this heuristic:

YAML constructOutcome 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

ComponentRoleLanguageABP Integration
cosmix-lib-ampABP wire format libraryRustParse/serialize, BTreeMap headers + body
cosmix-nodedPer-node ABP broker + config + monitor + loggerRustLocal ABP router, service registry, topic broker, mesh peer
cosmix-disp-skiaABP Display Protocol rendererRustRegisters as display service; receives ui.* commands, renders native UI
cosmix-maildJMAP + SMTP mail serverRustMail operations as ABP commands
cosmix-webdWeb server + CMS APIRustABP WebSocket gateway for browsers
cosmix-syncdFile sync (syncthing wrapper)RustSync operations as ABP commands
cosmix-mcpMCP bridge for Claude CodeRustBridges AI tool calls to ABP
cosmix-mixMix interpreter + shellRustsend/address/emit generate ABP
cosmix-agentdAI agent loop daemonRustLLM 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.

WorkloadExamplesTransportStatus
App↔app conversationui.window, file.list, noded.pingWebSocket via brokerworking
Hot-path Rust↔RustPointer events, frame callbacks (≥60Hz)postcard over Unix socket / TCPplanned (Phase B/C)
Bulk binary transferFile sync blocks, blob replication, backupspostcard over TCP/WG; iroh-blobs as candidatecandidate evaluation pending — see §8.3
Real-time mediaVoice, video, screen shareWebRTC (str0m)planned (calld)
Cross-mesh peer connectFuture: peers across WG domainsiroh-net as candidate (constitutional implications)deferred — see §8.5
Topic fan-out (single mesh)world.* retained topics, event broadcastsABP topic broker via local brokerworking
Topic fan-out (federation scale)Future: gossip across many nodesiroh-gossip as candidatedeferred

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.

PathLatencyStatus
Mix → broker WebSocket → service~0.5msWorking
Service → broker → service (local)~0.5msWorking
Broker → WireGuard WS → remote broker → service~2-5msWorking
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:

PathUse case
Server ↔ browserAudio playback (TTS), screenshots, file transfer
Browser ↔ browserVoice chat, screen share (peer-to-peer via ICE)
Server ↔ serverAudio/video relay between nodes

Signalling flow — WebRTC connections bootstrap over the existing ABP WebSocket:

  1. Browser sends ABP request: command: webrtc-offer with SDP in body
  2. Server responds: SDP answer + ICE candidates in ABP response body
  3. WebRTC data channel opens — binary streams flow directly
  4. 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-net as a transport inside WireGuard is a tech choice — no constitutional change. The trust domain remains the WG /24.
  • Adopting iroh-net as 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

NodeWireGuard IPRoleServices
alpha192.0.2.5Desktop / devnoded, display (cosmix-disp-skia), maild, mcp, agentd
beta192.0.2.3Mail / web (LAN)noded, maild, webd
gamma192.0.2.4Mail / web (LAN)noded, maild, webd
delta192.0.2.9Public VPSnoded, 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

TierConnectionTrustExamples
Mesh nodesAlways-on, WireGuardTrusted (WG /24 is the trust domain)alpha, beta, delta, gamma
Browser clientsEphemeral, WebSocket via webdAuthenticated per-sessionAny 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

TransportSecurity
Unix socketFile permissions (0700), user-namespace isolation
WireGuard meshAuthenticated encryption (Curve25519 + ChaCha20-Poly1305)
Axum WebSocketTLS + 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 formatABP 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 routeFlat headers — route on first 3 lines
Pretty-printing adds 300%+ overheadAlready human-readable
Body must be escaped/encodedBody 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

ComponentStatus
cosmix-lib-ampABP wire format library (parse, serialize, BTreeMap headers)
cosmix-nodedPer-node ABP broker + config + monitor + logger — local ABP router and mesh peer
cosmix-disp-skiaArchived ABP Display Protocol renderer; source retained at cos tag amp-display-archive. Current desktop applications are native Bevy/wgpu + CTK.
cosmix-maildJMAP + SMTP mail server with ABP commands
cosmix-mcpMCP bridge for Claude Code
cosmix-mixMix interpreter (115 builtins) + shell + ABP handler
cosmix-lib-meshWireGuard mesh networking, WebSocket peer sync
cosmix-indexdSemantic indexing + vector storage (candle + sqlite-vec)
Topic pub/subBroker-mediated retained-message topic extension (see 2026-04-10-03-bus-topic-pubsub.md)
Mix scriptingsend/address/emit ABP keywords, on event handlers

13.2 In Progress

ComponentStatus
cosmix-syncdSyncthing wrapper with ABP surface (design complete)
cosmix-agentdAI agent loop daemon with tool registry
cosmix-dopusDual-pane file manager (Mix script, design complete)

13.3 Planned

ComponentStatus
cosmix-shellWindow management policy layer (Phase B)
cosmix-compSmithay-based Wayland compositor (Phase C)
cosmix-calldWebRTC audio/video calling (parked)
cosmix-webdWeb 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

DecisionChoiceRationale
Wire formatABP (markdown frontmatter) everywhereThree-reader principle; one parser for all transports
Desktop renderingNative Bevy/wgpu applications sharing CTKSemantic ABP app-control ports; ui.* renderer lane archived under _decisions/2026-07-18-amp-as-control-plane.md
IPC routingPer-node broker (cosmix-noded) via WebSocketLocal router, service discovery, topic broker, mesh peer
ScriptingMix (pure Rust, ARexx-inspired)Native ABP keywords, 115 builtins, shell-capable
Web frameworkAxumRust-native, tokio-based, tower middleware. Used by cosmix-webd for HTTP/WS.
Mail servercosmix-maildJMAP + SMTP, single binary, SQLite storage
DatabaseSQLite (rusqlite)Node-local simplicity, no external DB server
Mesh transportWebSocket over WireGuardAuthenticated encryption, /24 trust domain
Binary hot pathpostcard (compositor boundary only)60-165Hz pointer events, not for apps
Message IDsUUID v7Time-ordered, globally unique, sortable
Error codesARexx convention (0/5/10/20)Simple, memorable, sufficient
Process managementsystemdProven, universal on Linux
Async runtimetokioDe facto Rust async standard
AllocatormimallocAll binaries
ContainersIncus / ProxmoxNever Docker
No Python depsRust daemons or Mix scriptsKeeps 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