Cosmix Specification Suite · v0.3.0

Every app is a service.
Every service speaks AMP.

A sovereign intelligence stack — mesh networking, mail, file sync, desktop rendering, and AI inference. All Rust. One wire format. The Amiga that could have been, rebuilt for the mesh era.

Chapters 07 · 00 → 06
Status Draft · stable
Revised 2026-04-18
Lineage AmigaOS · ARexx · Exec
0 Prelude
Chapter 0

Cosmix Specification Suite

version 0.3.0status stabledate 2026-04-18

Cosmix Specification Suite

Reading guide for the Cosmix protocol and design specifications. Chapters are ordered bottom-to-top through the stack — start at Chapter 01 (the wire protocol that everything speaks) and work up to Chapter 06 (the design system that governs what users see).


The Stack

Cosmix is a sovereign intelligence stack: mesh networking, mail, file sync, desktop rendering, and AI inference — all Rust, all speaking one protocol. The architecture draws from AmigaOS, where a single ROM held the kernel, graphics, windowing, and scripting bus in one coherent package. Cosmix recreates that coherence across Linux process boundaries.

The Amiga Mapping

AmigaOS Kickstart put Exec (kernel + message passing), Intuition (windows + gadgets), graphics.library (drawing), and layers.library (overlapping regions) in one ROM, one address space. Crucially, Intuition was simultaneously the compositor, the window manager, and the widget toolkit — because they shared memory structures (RastPort, Window, Screen, Layer) rather than communicating over IPC.

Linux + Wayland necessarily separates these concerns by process boundary. You cannot have a single-address-space Intuition. What you can have is two cooperating components that together provide Intuition's functionality, unified by shared vocabulary and a clean protocol at the boundary.

AmigaOS layer cosmix equivalent Notes
Kickstart ROM (Exec kernel) Linux kernel + DRM/KMS + libinput Exec's message passing ~ kernel syscalls
graphics.library (drawing) wgpu + cosmic-text + tiny-skia 2D/3D rendering primitives
layers.library (overlapping regions) Wayland compositor (cosmix-comp) "Who's on top" logic
Intuition (window + gadget manager) cosmix-shell + cosmix-deskd The split layer (see below)
Workbench (shell / launcher) cosmix-menu + deskd panel host User-visible environment
ARexx (scripting bus) AMP + Mix The direct analog
Applications cosmix apps (dopus, maild, etc.) Same layer

The Intuition Split

Intuition maps to two cosmix components:

  • cosmix-shell — the window-management half. Surface placement, stacking, focus policy, workspace semantics, global shortcuts, modal/popover semantics, input routing to surfaces. Speaks postcard to the compositor (hot path) and AMP to applications (orchestration).

  • cosmix-deskd — the widget-toolkit half. Panel content rendering, widget state, hit-testing, widget-level events, text layout, the visual system. Speaks AMP to applications. Does not speak directly to the compositor.

The user experience of Intuition-like coherence is achieved through: 1. Shared vocabulary — both agree on what a "panel" is, what layers mean 2. Consistent visual system — same design tokens, typography, color palette 3. Unified event flow — apps don't know which component handled what 4. AMP as the app-facing protocol — the split is invisible to applications

The Protocols

  • Wayland — standard Linux display protocol between compositor and its clients. Third-party apps and (currently) deskd speak Wayland to the compositor. cosmix does not define or extend Wayland.
  • AMP (human-readable markdown frontmatter) — everything applications touch. Commands, events, UI declarations, data updates. Debuggable with cat.
  • postcard (binary, Rust-to-Rust) — planned for Phase B/C. Compact binary serde (varint-encoded, no field names, ~60ns serialize) for the hot path between compositor and shell (60-165Hz pointer motion, frame callbacks, surface damage). Requires both sides to share Rust type definitions — unsuitable for cross-language or application-facing use. Also the planned transport for bulk Rust-to-Rust data transfer between mesh nodes (file sync blocks, binary replication) over WireGuard TCP, replacing the need for WebRTC data channels. Applications never see postcard.
  • WebRTC (via str0m) — planned, parked until calld. Media streams (audio/video) for voice/video calls between mesh nodes. AMP handles signaling (SDP/ICE exchange); WebRTC handles the media plane. calld is the only planned WebRTC consumer — WebRTC's complexity (ICE/DTLS/SCTP) exists to solve NAT traversal and encryption, both of which WireGuard already provides. For binary data transfer, postcard over WireGuard TCP is simpler (no ICE negotiation, no double encryption, instant connection establishment).

Encryption Layering

WireGuard provides network-layer encryption across the mesh. Cosmix-native protocols (AMP, postcard) rely on this exclusively — no per-protocol TLS, no certificate management, no key exchange. The WG /24 subnet is the trust domain and WireGuard membership is the credential.

Third-party wrapped services may add redundant encryption. For example, syncthing's BEP mandates TLS 1.2+ with mutual certificate auth (the device ID is the certificate hash). Running syncthing over WireGuard means double encryption — WG at the network layer, TLS at the application layer. The performance cost is negligible (AES-NI), but the architectural redundancy is one motivation for an eventual native sync engine using postcard over raw TCP on WireGuard, where exactly one encryption layer handles transport security.

Protocol Boundary Table

The rule: if an application could ever need to send or receive it, it's AMP. If it's internal plumbing between cosmix's own Rust components, it's postcard. If it's real-time media, it's WebRTC.

Boundary Protocol Rationale
App↔app (same node) AMP over hub Language-agnostic, debuggable
App↔app (cross-node) AMP over WebSocket/WG Mesh-transparent
Widget↔widget (same panel) Function calls Same process, same memory
Script↔deskd AMP over hub Apps must stay language-agnostic
Shell↔deskd (Phase B) postcard Rust↔Rust, 165Hz, shared types
Shell↔compositor (Phase C) postcard Rust↔Rust, frame-rate events
File sync blocks (native engine) postcard over TCP/WG Bulk binary, no ICE/DTLS overhead
Audio/video calls (calld) WebRTC (str0m) Media pipeline, jitter, echo cancellation
Federated panels (cross-mesh) AMP over SMTP or WebSocket Sandboxed, origin-tracked

What the Shell Owns

cosmix-shell is the Intuition window management half:

  • Surface placement policy — where new surfaces go, what size, which workspace
  • Stacking and focus policy — which surface is on top, keyboard focus rules
  • Workspace semantics — how many, how users move between them
  • Global keyboard shortcuts — Super+Tab, workspace switches, launcher
  • Panel placement — tray, status bar, docks
  • Modal and popover semantics — what is modal to what
  • Input routing — compositor says "pointer at X,Y"; shell says "route to surface S" (in Phase B on cosmic-comp, input routing is handled natively by the compositor; cosmix-shell only sees what layer-shell protocol grants it)

What the Display Service Owns

cosmix-deskd is the Intuition widget toolkit half:

  • Panel content rendering — given a ui.panel message, paint pixels
  • Widget state — scroll positions, selection, column widths, focus-within-panel
  • Hit-testing — which widget did the user click?
  • Widget-level events — mouse click at X,Y → select on widget files
  • Text layout, color rendering, the visual system
  • Internal interactions — scrolling, drag-resize, column-resize (don't escape the panel)

The display service speaks AMP to applications and does not speak directly to the compositor. It receives input events from the shell (or currently from winit, which mediates the same flow via Wayland).

The Rust Ecosystem

Concern Crate Notes
Compositor smithay Only serious Rust Wayland compositor library. Used by cosmic-comp, niri, anvil.
Panel rendering wgpu + tiny-skia + cosmic-text Current stack, working, pure Rust
Layout taffy Flexbox-like, already in use
Windowing (current) winit Transitional — will be replaced by direct cosmix-shell integration in Phase B/C
Hot-path IPC postcard Binary serde for compositor↔shell (60-165Hz)
App-facing IPC cosmix-lib-amp AMP wire format for everything else

Phasing

Phase Scope Begins after
A deskd matures as the widget-toolkit half of Intuition, running on cosmic-comp via winit Current phase
B cosmix-shell emerges as a wlr-layer-shell app on cosmic-comp — cosmix window management policy without owning the compositor First cosmix apps mature enough to stress-test shell policy
C cosmix-comp replaces cosmic-comp, completing the fully cosmix-native stack Shell is mature and battle-tested

Phase B is the key insight. Before replacing the compositor, cosmix-shell can run as a privileged application using Wayland layer-shell protocols to implement cosmix-specific window management on top of cosmic-comp. This is what status bars, notification daemons, and launchers do on various Wayland compositors. It can coexist with cosmic-comp indefinitely, and teaches exactly what cosmix-comp must provide when the time comes.

Phase ordering discipline: build the shell before the compositor. Writing the compositor first means guessing at what the shell needs. Writing the shell first, on cosmic-comp, teaches you exactly what cosmix-comp must provide.

Design Discipline

  • winit is transitional. Panel lifecycle and input event flow should feel like cosmix abstractions with winit as implementation detail. Features that depend on winit's specific model will need unwinding in Phase B/C.

  • Sketch the shell↔display service protocol early. Before cosmix-shell exists, sketch what AMP messages between shell and deskd look like: panel creation, workspace changes, surface geometry, focus changes. This prevents accidentally baking winit assumptions into deskd.

  • Fixed widget registry is correct. Amiga Boopsi (later Intuition) added extensible widget classes. cosmix goes the opposite direction: no runtime widget extensibility, all extensibility at the app level via AMP. This is right for mesh-addressable UIs where the renderer must be deterministic across nodes.

The Unavoidable IPC Cost

Amiga Intuition was fast partly because window management, rendering, and event dispatch were function calls in one address space. cosmix accepts the cost Wayland imposes — every pointer event crosses at least one process boundary. The mitigation is the postcard-vs-AMP split: binary serde for the hot path, human-readable text for orchestration. The target is "fast enough that users don't notice the difference," which is achievable on modern hardware.


Glossary

Terms used across the spec suite. Defined once here; chapters reference this section rather than re-defining.

Term Definition Defined in
AMP AppMesh Protocol. Human-readable wire format using markdown frontmatter headers + body. The application-facing protocol for all cosmix IPC. Ch01
body The content portion of an AMP message, after the closing --- of the header block. For ui.panel, this is GFM markdown. For ui.data, JSON. Ch01 §1.3
command The command: header value identifying the message type (e.g., ui.panel, hub.ping). Ch01 §5.5, Ch02
gadget An interactive region within a rendered widget. Gadgets are HitRects — invisible rectangles the renderer uses for hit-testing and (future) accessibility. Not visible to the user. Ch06 §2.3
headers Key-value pairs in the ----delimited frontmatter of an AMP message. Route messages, set properties, carry metadata. Ch01 §1.2
hub The central message router on each node (cosmix-noded). Routes AMP messages between processes, manages service registry, panel registry, and topic broker. Ch01 §9
mesh The WireGuard /24 network connecting cosmix nodes. All nodes in the mesh share a single trust domain. Ch01 §10
Mix ARexx-inspired scripting language with native AMP keywords (send, emit, address, on). Works standalone or with a hub. Ch04
node A machine running cosmix-noded, identified by its WireGuard IP and hostname. Ch01 §10
panel A top-level UI surface created by a process via ui.panel. Panels have an ID, owner process, layer, and markdown body that the display service renders. Panels are not windows — they have no inherent chrome. Ch05 §3.1, Ch06 §2.1
postcard Binary Rust-to-Rust serialization format (varint-encoded, serde-based). Planned for compositor↔shell hot path and bulk mesh data transfer. Not application-facing. Ch00
process Any program connected to the hub — a Mix script, a daemon, an AI agent. Processes own application state and send AMP commands to create/update UI. Ch05 §0.4
service A process that registers a name with the hub (e.g., maild, deskd). Services are addressable by name; anonymous processes are not. Ch01 §9, Ch02 §3
surface The display model's abstraction of a renderable area. Every panel maps to one surface. Surfaces have identity, size, position, layer, and owner. Ch06 §2.1
topic A named pub/sub channel in the hub's topic broker. Producers publish snapshots; subscribers receive fan-out deliveries. Ch03 §3.11.1
widget A UI element within a panel, declared via markdown code blocks (~~~textinput id=to). Widgets have properties, emit events, and are identified by panel-scoped IDs. Ch05 §6, Ch06 §2.2

Chapter Format

All chapters use YAML frontmatter — --- delimited key: value headers followed by a markdown body. This is structurally identical to AMP wire format: a spec document is a valid AMP message (the frontmatter fields parse as AMP headers, the document body parses as the AMP body). The three-reader principle applied to the specs themselves.

Required fields:

---
title: <chapter title>
chapter: <number>
version: <semver or semver-draft>
status: stable | draft
date: <last meaningful update, YYYY-MM-DD>
supersedes: <filename or none>      # optional
amends: <filename>                  # optional — for spec deltas
companion: <filename>               # optional — for paired specs
---

Specs reference each other by chapter filename (e.g., 05_amp-display-protocol.md), not by date-prefixed names.


Chapters

Ch Title Layer Status
01 AMP Wire Protocol Foundation v0.3.0 draft
02 AMP Command Vocabulary Foundation v0.3.0 draft
03 AMP Topic Pub/Sub Messaging v0.3.0 draft
04 Mix Language Reference Scripting v0.3.0 stable
05 AMP Display Protocol Display v0.3.0 draft
06 Cosmix Display Model Display v0.3.0 draft

Reading Order

Start here: Chapter 01 defines the AMP message format — the single wire protocol that every cosmix component speaks. This is the Exec of the stack: understand it and you understand how everything communicates.

Then: Chapter 02 (command naming conventions) and Chapter 03 (reactive pub/sub) extend the protocol with standard patterns. These are the shared libraries that all services use.

Scripting: Chapter 04 defines Mix, a systems scripting language in the ARexx tradition. Mix works as a standalone shell (filesystem, HTTP, JSON, regex, crypto, process management) and gains AMP mesh messaging (send, address, emit) when a hub is available. Like ARexx, the language is useful in both contexts.

Display: Chapters 05 and 06 define how processes declare user interfaces and how the display service renders them. Chapter 05 is the protocol specification (wire format, commands, widget types). Chapter 06 is the design system (the "Intuition" — visual language, interaction model, composition patterns).

Planned Chapters

These chapters don't exist yet. "Planned" means the component exists and could be documented now. "Deferred" means the component doesn't exist yet and the chapter will be written alongside it.

Ch Title Layer Status
04a Mix Builtin Reference Scripting planned (115 builtins, currently inline in Ch 04)
07 Mesh Networking Infrastructure planned
08 Hub Architecture Infrastructure planned
09 Daemon Infrastructure Infrastructure planned
10 Compositor Display deferred (Phase C)
11 Shell Display deferred (Phase B)

Historical Note

The spec suite was reorganized on 2026-04-18 from date-prefixed filenames (e.g., 2026-03-09-amp-v04-cosmix-specification.md) to numbered chapters. Chapters are versioned by content, not by date. Historical discussions in _doc/ and _journal/ may reference the older filenames — those references describe what was true at the time and are not updated retroactively.

1 Foundation
Chapter 1

AMP — Cosmix Wire Protocol Specification

version 0.3.0status draftdate 2026-04-18

AMP — Cosmix Wire Protocol Specification

Every app is a service. Every service speaks AMP. Every node is reachable. One protocol, one language, one wire format — from Unix socket to mesh.


1. What AMP 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.

AMP (AppMesh 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 the hub. Mix is the scripting language. Rust is the engine. AMP is the wire format everywhere — hub WebSockets, mesh peering, and log files.

Amiga / ARexx AMP / Cosmix
ADDRESS 'APP' 'COMMAND' send "maild" "account.list" (Mix)
ARexx port name AMP service name: maild on node cachyos
rx script.rexx mix script.mix
REXX (universal glue) Mix (ARexx-inspired, pure Rust)
Single machine IPC Multi-node mesh (WireGuard + AMP)

Why Mix

  • ARexx reborn — Mix is an ARexx-inspired language with native AMP 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 (~/.local/bin/mix), not just a scripting language. Interactive REPL, job control, PATH search.

Why Rust (everything else)

  • One language for everything — desktop renderer (cosmix-deskd), 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 AMP 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 hub route, and a log file.

2. Design Principle: Three-Reader Format

Every AMP 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 AMP.

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
AMP Headers route deterministically cat message.amp.md Reads natively — it's markdown

An AI agent consuming an AMP 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.cosmix.cachyos.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 AMP messages). No JSON schema to update.
  • Self-describing commandscommand: search, args: {"query": "invoices"}, from: cosmix-mail.cosmix.mko.amp tells the agent what this does.
  • Multi-agent coordination becomes conversation — two agents on different nodes exchanging AMP messages are passing structured text to each other. Headers handle routing; bodies handle reasoning.

3. AMP Everywhere: One Protocol, All Transports

v0.4's defining change: AMP is no longer reserved for mesh communication. Every byte that crosses a cosmix boundary — local Unix socket, mesh WebSocket, log file — uses AMP framing. There is no "internal format" vs "wire format" distinction. There is only AMP.

Why not length-prefixed JSON for local sockets?

The v0.3 spec assumed local IPC would use a simpler binary-framed JSON format, with AMP 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 AMP --- 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 AMP bodies may contain markdown, and markdown horizontal rules (--- on their own line) collide with the ---\n delimiter, AMP 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

Transport Where Format Notes
WebSocket Service ↔ hub (local) AMP frames in WS text messages ws://172.16.2.x:4200
WebSocket Node ↔ node (mesh) AMP frames in WS text messages Over WireGuard tunnel
WebSocket Browser ↔ webd AMP frames in WS text messages Authenticated per-session
Log file Debug/audit AMP messages concatenated cat and grep just work
CLI pipe mix stdin/stdout AMP Single request-response

One parser. One serialiser. One test suite. One mental model.


4. The AMP Address

AMP uses DNS-native addressing. Addresses read left-to-right from most-specific to least-specific:

[port].[app].[node].amp

Examples:

maild.cosmix.cachyos.amp           → mail server on cachyos
deskd.cosmix.cachyos.amp           → display service on cachyos
noded.cosmix.cachyos.amp           → hub on cachyos
maild.cosmix.mko.amp               → mail server on mko
webd.cosmix.mko.amp                → web server on mko
  • .amp is the mesh TLD
  • Node names (cachyos, mko, mmc) are subdomains under .amp
  • cosmix is the application namespace (always cosmix for this ecosystem)
  • Service names (maild, deskd, noded) are the leaf — the actual service endpoint

Local shorthand: maild alone implies .cosmix.<local-node>.amp when running locally. The hub resolves short names to local service connections.

Remote addressing: maild@mko (or maild.cosmix.mko.amp in full form) tells the hub to route through the mesh to the mko node. If mko is not in the WireGuard mesh, resolution fails with error code 20.


5. The AMP Message

AMP 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 AMP 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).

Why ---\nEOM\n and not just ---\n? AMP 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.

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:

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.cosmix.cachyos.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: script.cosmix.cachyos.amp
to: cosmix-mail.cosmix.cachyos.amp
command: status
ttl: 30
---

Shape 3 — Command with args:

---
amp: 1
type: request
id: 0192b3a4-5e6f-7890-abcd-ef1234567891
from: script.cosmix.cachyos.amp
to: maild.cosmix.cachyos.amp
command: 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: cosmix-mail.cosmix.cachyos.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.cosmix.cachyos.amp
command: 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 AMP port address
to no Target AMP 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). Hub 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 hub 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.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 AMP 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
CLOSE Close current document/tab rc: 0 or 10
QUIT Graceful shutdown rc: 0

Services extend this vocabulary with domain-specific commands (e.g., account.list, email.query for maild; share.list, peer.add for syncd). See 02_amp-command-vocabulary.md for naming conventions.


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 AMP Keywords

Mix scripts don't parse AMP manually — the interpreter has native AMP keywords:

-- Send a command to a service via the hub
send "maild" "account.list"

-- Address a service for multiple commands
address "deskd"
  send "ui.panel" 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: account.list
to: maild
from: script
---

The hub 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;
        }
    }
}

7. The Cosmix Stack

Every component in the cosmix stack speaks AMP natively. The hub (cosmix-noded) is the central router — all services connect to it via WebSocket and exchange AMP messages through it.

7.1 Component Map

Component Role Language AMP Integration
cosmix-lib-amp AMP wire format library Rust Parse/serialize, BTreeMap headers + body
cosmix-noded Hub + config + monitor + logger Rust Central AMP router, service registry, topic broker
cosmix-deskd AMP Display Protocol renderer Rust Receives ui.* commands, renders native UI
cosmix-maild JMAP + SMTP mail server Rust Mail operations as AMP commands
cosmix-webd Web server + CMS API Rust AMP WebSocket gateway for browsers
cosmix-syncd File sync (syncthing wrapper) Rust Sync operations as AMP commands
cosmix-mcp MCP bridge for Claude Code Rust Bridges AI tool calls to AMP
cosmix-mix Mix interpreter + shell Rust send/address/emit generate AMP
cosmix-agentd AI agent loop daemon Rust LLM tool use over AMP

7.2 Desktop Apps (AMP Display Protocol)

Desktop applications are Mix scripts that send ui.panel commands to cosmix-deskd via the hub. The display service renders markdown + widget properties into native UI (winit + wgpu + taffy). User interactions flow back as AMP events:

-- dopus.mix — dual-pane file manager
emit "display" ui.panel 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. See `05_amp-display-protocol.md` for the
full protocol specification.

### 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: account.list
to: maild
from: script
---

The hub routes the message to the maild service, which responds:

---
command: 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 AMP mesh:

Browser → WebSocket → cosmix-webd → hub → target service

The same AMP 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 hub and exposes mail operations as AMP commands:

---
command: 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 AMP messages between meshes:

Mix script → hub (local noded) → WireGuard WS → remote noded → remote service

The message is AMP the entire way. No format translation at any boundary.


8. Transport Layers

Transport is an implementation detail — callers address services, not transports. AMP separates two planes:

  • Control plane (AMP) — commands, events, heartbeats, discovery, text data
  • Data plane (WebRTC) — binary streams: audio, video, screen share, file transfer

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 AMP. See 00_index.md §Three Protocols for the full topology.

8.1 Control Plane

Path Latency Status
Mix → hub WebSocket → service ~0.5ms Working
Service → hub → service (local) ~0.5ms Working
Hub → WireGuard WS → remote hub → service ~2-5ms Working
Browser → webd WS → hub → service ~2ms est. Design phase

Local path (hot path for scripting):

Mix script → hub WS → AMP route → service WS → command handler → AMP response

Mesh path (cross-node):

Mix script → hub → WireGuard WS → remote hub → remote service

Browser path (web access):

Browser → webd WS → AMP frame → hub → service → AMP response → webd WS → browser

8.2 Data Plane (WebRTC)

Binary streams use WebRTC data channels, negotiated via AMP 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 AMP WebSocket:

  1. Browser sends AMP request: command: webrtc-offer with SDP in body
  2. Server responds: SDP answer + ICE candidates in AMP response body
  3. WebRTC data channel opens — binary streams flow directly
  4. AMP control plane continues alongside on WebSocket

This keeps AMP clean (text-only, human-readable, debuggable) while WebRTC handles binary heavy-lifting.


9. Service Discovery and Lifecycle

9.1 Service Registration

When a service starts, it connects to the hub (cosmix-noded) via WebSocket and sends a hub.register message declaring its name and capabilities:

---
command: hub.register
args: {"name": "maild", "version": "0.1.0"}
---

The hub adds the service to its routing table. All subsequent messages addressed to that service name are forwarded over the WebSocket connection.

9.2 Service Heartbeat

The hub sends periodic hub.ping messages. Services must respond within the configured timeout or be deregistered:

---
command: hub.ping
---

Response:

---
command: hub.ping
rc: 0
---

9.3 Service Discovery by Scripts

-- List all registered services
$services = send "hub" "hub.list"
say $services

-- 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 (hub routes locally)
$status = send "maild" "account.list"

-- Remote service (hub routes via mesh)
$status = send "maild@mko" "account.list"

10. The Mesh

10.1 Nodes

Node WireGuard IP Role Services
cachyos 172.16.2.5 Desktop / dev noded, deskd, maild, mcp, agentd
mko 172.16.2.210 Mail / web (LAN) noded, maild, webd
gcwg 172.16.2.4 Mail / web (LAN) noded, maild, webd
mmc 172.16.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 (172.16.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) cachyos, mko, mmc, gcwg
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 hub. The WireGuard /24 subnet is the trust domain — no per-message auth within the mesh.

10.3 Mesh Heartbeat

cosmix-noded exchanges heartbeat AMP messages between peered nodes:

---
command: hub.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 AMP

AMP messages are designed to be loggable and debuggable. Secrets (API keys, tokens, passwords) must NEVER appear in AMP headers or bodies. Use reference tokens or session IDs instead.


12. Design Rationale

12.1 Why --- Frontmatter, Not JSON

JSON wire format AMP 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). AMP headers look like YAML but are intentionally restricted to flat key: value — no indentation, no nesting, no type coercion. A correct AMP header parser is ~15 lines of code in any language.

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. AMP 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. AMP ports are self-describing via HELP.
  • MCP messages are opaque to humans without tooling. AMP messages are cat-able.
  • MCP is request-response only. AMP supports events, streams, and broadcasts.
  • MCP has no addressing model. AMP has DNS-native mesh addressing.

AMP and MCP can coexist: a cosmix MCP server can bridge AI tool calls to AMP port commands.


13. What Exists Today (as of 2026-04-18)

13.1 Working

Component Status
cosmix-lib-amp AMP wire format library (parse, serialize, BTreeMap headers)
cosmix-noded Hub + config + monitor + logger — central AMP router
cosmix-deskd AMP Display Protocol renderer (winit + wgpu + taffy, 34+ widgets)
cosmix-maild JMAP + SMTP mail server with AMP commands
cosmix-mcp MCP bridge for Claude Code
cosmix-mix Mix interpreter (115 builtins) + shell + AMP handler
cosmix-lib-mesh WireGuard mesh networking, WebSocket peer sync
cosmix-indexd Semantic indexing + vector storage (candle + sqlite-vec)
Topic pub/sub Hub-mediated retained-message broker (see 03_amp-topic-pubsub.md)
Mix scripting send/address/emit AMP keywords, on event handlers

13.2 In Progress

Component Status
cosmix-syncd Syncthing wrapper with AMP 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

AMP is the foundation layer — every cosmix component speaks it. The full stack mapping (from Amiga ROM to Intuition-equivalent desktop) is defined in 00_index.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 AMP (markdown frontmatter) everywhere Three-reader principle; one parser for all transports
Desktop rendering cosmix-deskd (winit + wgpu + taffy) AMP Display Protocol, no web framework, no WebView
IPC routing Hub (cosmix-noded) via WebSocket Central router, service discovery, topic broker
Scripting Mix (pure Rust, ARexx-inspired) Native AMP 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.5 — active development Supersedes: AMP v0.4 (2026-03-09), v0.3 (2026-03-02)

2 Vocabulary
Chapter 2

AMP Standard Command Vocabulary

version 0.3.0status draftdate 2026-04-18

AMP Standard Command Vocabulary

This chapter defines how AMP commands are named, what universal commands every service must implement, and how scripts discover a service's capabilities. It is the naming-convention reference for the entire stack — every command in every chapter follows these rules.


1. Command Naming Convention

<service>.<verb>[-<noun>]
  • service — the hub registration name (maild, syncd, deskd, hub)
  • verb — what to do (get, set, open, list, close)
  • noun — optional target within the service (path, content, status)

Rules:

  • Hyphen-separated for multi-word nouns: get-content, not getContent
  • Lowercase only. No underscores in command names.
  • Verbs are drawn from the standard vocabulary (§2) where possible
  • Service-specific verbs are permitted when standard verbs don't fit
  • The service prefix is required in all commands. account.list is correct; list-accounts is wrong.

Examples:

Command Service Verb Noun
maild.account.list maild list account
syncd.share.add syncd add share
hub.list hub list
deskd.ui.panel deskd panel

Note on display commands: Commands prefixed ui.* and menu.* are display-surface introspection commands handled by the display service. They follow the same naming convention but are specified in 05_amp-display-protocol.md §3.11 rather than here.


2. Standard Verb Vocabulary

These verbs have consistent semantics across all cosmix services. Use them before inventing service-specific verbs.

Content verbs

Verb Semantics Example
open Load, display, or begin working with a resource open {"path": "/etc/hosts"}
close Release the current resource close
get Read a specific item or property get {"id": "inbox"}
set Write a specific item or property set {"id": "inbox", "name": "Primary"}
list Enumerate items of a type list or list {"filter": "active"}

Lifecycle verbs

Verb Semantics Example
status Return current state or health status{state, uptime, ...}
refresh Force data reload from source refresh
save Persist current state save
add Create a new item add {"name": "work", "path": "/sync/work"}
remove Delete an item remove {"id": "..."}

Mutation verbs

Verb Semantics Example
start Begin a long-running operation start {"task": "full-sync"}
stop Cancel a long-running operation stop {"task": "full-sync"}
pause Suspend without canceling pause {"id": "..."}
resume Continue after pause resume {"id": "..."}

When standard verbs don't fit: use a domain-specific verb and document it in the service's command reference. Example: syncd.conflict.resolve uses resolve because none of the standard verbs capture the semantics of conflict resolution.


3. Universal Service Commands

Every AMP service SHOULD support these commands. They are the minimum surface that enables ARexx-style discovery and scripting. Chapter 01 §5.7 introduces these; this section provides full signatures.

Command Description Args Returns
HELP List all commands this service accepts none [{name, description, args}] as JSON body
INFO Service identity and capabilities none {name, version, description} as JSON body
QUIT Graceful shutdown none rc: 0 then disconnect

Notes:

  • HELP is the discovery entry point. Scripts call HELP first to learn what a service can do. The response is a JSON array of command descriptors.
  • INFO provides metadata for fleet inventory and version tracking.
  • QUIT requests graceful shutdown. The service should clean up, flush state, and disconnect from the hub. systemd will restart it if configured to.
  • ACTIVATE, OPEN, SAVE, SAVEAS, CLOSE from Chapter 01 §5.7 are optional — they apply to services with a user-visible surface (display panels) but not to headless daemons.

4. Hub Commands

The hub (cosmix-noded) exposes its own command surface for service management and mesh discovery.

Command Description Args Returns
hub.register Register a service on the hub {name, version} rc: 0
hub.list List all registered services none [{name, version, node}]
hub.ping Heartbeat / health check none rc: 0 with optional {extensions}
hub.discover List services on remote mesh nodes {node} or none [{name, node, version}]

Topic broker commands (topic.*) are specified in 03_amp-topic-pubsub.md — they are hub extensions, not universal service commands.


5. Return Code Conventions

AMP return codes follow the ARexx convention. See Chapter 01 §5.6 for the authoritative definition:

Code Meaning When to use
0 Success Command executed normally
5 Warning Partial result, non-fatal issue (e.g., some items skipped)
10 Error Command failed but service is healthy (e.g., bad args, not found)
20 Failure Severe error, service may be degraded

Absence of rc: in a response implies rc: 0.


6. Extension Guidance

When adding commands to a new or existing service:

  1. Name it correctly. service.verb-noun form, standard verbs first.
  2. Document it in HELP. Every command must appear in the service's HELP response with a description and expected args.
  3. Use standard return codes. Don't invent new codes; the 0/5/10/20 range is sufficient.
  4. Args are JSON objects. Use args: header for command parameters, not positional encoding.
  5. Bodies are for content. Structured data goes in args: or json:. Bodies are for markdown, prose, or large payloads.
  6. Don't duplicate display commands. If your service needs widget introspection, those commands are in 05_amp-display-protocol.md §3.11. Don't reinvent ui.list or ui.get.

Document created: 2026-03-29, rewritten 2026-04-18 Supersedes: AMP Command Vocabulary v0.1 (partial command registry)

3 Messaging
Chapter 3

AMP Topic Pub/Sub

version 0.3.0status draftdate 2026-04-10

AMP Topic Pub/Sub

This document introduces a topic pub/sub primitive in the hub (cosmix-noded) and the amendments to 05_amp-display-protocol.md required to support it. The primitive enables reactive dashboards where a producer publishes state once and zero-or-more viewers receive current and future updates. The motivating use case is sysmon.mix auto-refreshing without a per-viewer process.

The design follows the MQTT retained-message and NATS JetStream KV watcher patterns, both of which have run the same "named channel with cached latest value replayed on subscribe" semantics in production for years. This is not a novel primitive; it is a deliberate reimplementation of a well-understood one in AMP framing.

1. Summary of changes

Existing section Change
0.3 Design Principles Principle 6 amended to clarify that caching opaque topic payloads is a routing optimization, not application state.
0.4 State Ownership Hub row gains explicit allowance for an opaque topic snapshot cache.
3.1 ui.panel New optional behavior header subscribe with atomic-swap lifecycle semantics on updates.
10.3 Orphan Handling Topic snapshot TTL aligned with panel orphan timeout.
14 Conformance Levels New note: topic.* commands are a hub extension and do not change renderer conformance levels.
hub.ping (hub-internal) Response body gains a versioned extensions map for capability discovery.
New § 3.11 topic.* command family: publish, subscribe, unsubscribe, subscriber_count, list, clear, plus broker-emitted topic.active / topic.idle.

All new behavior is additive. A renderer or process that implements only the pre-delta spec remains conformant; it simply cannot participate in topic pub/sub.

2. Amendments to existing sections

2.1 Amendment to § 0.3 — Design Principles

Append to Principle 6 ("Process owns state, display owns pixels"), after the sentence "The hub routes messages between them but owns no application state.":

The hub MAY cache opaque topic payloads (§ 3.11) as a routing optimization. Such a cache holds the verbatim bytes of the most recent publish per topic and is used solely to replay the latest value to new subscribers. The hub never parses, interprets, or diffs these payloads. This is the routing-buffer analogue of MQTT retained messages: bytes in, bytes out, addressed by topic name. It is not application state because the hub has no view into what the bytes mean — it cannot answer any question about them beyond "did one exist, and if so, what were its bytes."

Rationale: without this clarification, a strict reading of Principle 6 forbids any form of replay cache in the hub, forcing pub/sub to be implemented as a separate daemon or via a producer-pull model that breaks the "fresh subscriber sees current state immediately" property. The opacity constraint preserves the principle's intent (hub can't reason about app data) while admitting a 25-year-old industry pattern.

2.2 Amendment to § 0.4 — State Ownership

In the responsibility table, modify the Hub column of the Panel registry row from:

Tracks which panel IDs exist (for routing, wildcards, orphan detection)

to:

Tracks which panel IDs exist (for routing, wildcards, orphan detection). MAY additionally maintain an opaque topic snapshot cache (§ 3.11) keyed by topic name. Cached payloads are treated as bytes, not application state.

No other rows change.

2.3 Amendment to § 3.1 — ui.panel

Insert a new row in the Optional headers (behavior) table, after the collect_values row:

Header Type Default Description
subscribe string (none) Topic name to bind this panel to (§ 3.11). The display service MUST auto-issue topic.subscribe on panel creation and topic.unsubscribe on panel removal.

Add a new sub-section 3.1.2 subscribe header lifecycle after the grid layout sub-section:

When a ui.panel message includes the subscribe header, the display service establishes a topic binding for the panel. The binding lives for the lifetime of the panel and is managed entirely by the display service — the process owning the panel does not need to issue explicit topic.subscribe / topic.unsubscribe calls.

On panel creation (first ui.panel with a given id): - If subscribe is present and non-empty, the display service issues topic.subscribe name=<value> to the hub. - If subscribe is present and empty, it is treated as absent — no subscription. - If subscribe is absent, no subscription is created.

On panel update (subsequent ui.panel with an existing id): - If subscribe is absent, the existing binding (if any) is preserved. This matches the general ui.panel update rule that absent headers preserve stored values. - If subscribe is present and equals the current binding, no action. - If subscribe is present and differs from the current binding (including transition from unbound to bound), the display service MUST atomically: issue topic.unsubscribe for the old binding (if any), issue topic.subscribe for the new binding, and update the stored binding. The swap MUST be atomic from the perspective of subsequent topic deliveries — no delivery on the old binding may arrive after the new subscription is established. - If subscribe is present and empty, the existing binding (if any) is removed: issue topic.unsubscribe, clear the stored binding. This is the ui.panel "empty string clears header" rule applied to subscriptions.

On panel removal (ui.remove, orphan timeout, or TTL expiry), the display service MUST issue topic.unsubscribe for any active binding before removing the panel.

The subscribe header is purely sugar over the topic.subscribe / topic.unsubscribe commands in § 3.11. A process MAY issue those commands directly instead of (or in addition to) using the header.

2.4 Amendment to § 10.3 — Orphan Handling

Append a new sub-section 10.3.1 Topic snapshot lifetime after the orphan handling bullets:

Topic snapshots (§ 3.11) are tied to producer presence with a grace period matching the panel orphan timeout:

  • When a peer that has published to topic T disconnects, the hub marks the snapshot for T as stale. Subsequent subscribers receive the stale snapshot with a topic_stale: true header on the first delivery (see § 3.11).
  • If the same peer reconnects (identified by registered service name) and publishes to T before the orphan timeout elapses, the stale flag is cleared. Brief producer restarts are invisible to subscribers.
  • If the orphan timeout elapses without a new publish, the hub MAY purge the snapshot. New subscribers after purge receive no replay; they see only future publishes (if any).
  • Anonymous publishers (no registered service name) cannot be matched across reconnects. Their snapshots are considered stale the instant the publishing connection closes and are purged at the next orphan-timeout sweep.

The default orphan timeout from § 10.3 (60 seconds) applies unchanged. Implementations MAY expose a separate topic-snapshot-TTL configuration knob but SHOULD default it to the orphan timeout for consistency.

2.5 Amendment to § 14 — Conformance Levels

Append a new sub-section 14.4 Hub extensions after § 14.3:

The topic.* command family (§ 3.11) is a hub extension and does not affect renderer conformance levels. A renderer is unaware of the broker: it sees only messages that arrive through normal routing, regardless of whether those messages originated from a direct send or from a broker fan-out. The single renderer-facing feature is the subscribe header on ui.panel (§ 3.1.2), which a renderer MAY implement at any conformance level. A renderer that does not implement the subscribe header MUST ignore it (per the general "ignore unknown headers" rule) — panels continue to render, they simply do not auto-update from a topic.

A hub implementation either supports topic.* or it does not. A hub that does not support topic.* MUST respond to topic.* commands with RC 10 and the error body {"error": "topic_not_supported"}. Processes detect broker availability by reading the extensions field of the hub.ping response (§ 2.6) — capability discovery belongs in the handshake, not in the operation. A process that cannot reach the hub at all receives no response; a process that reaches a hub without broker support sees extensions.topic absent from the ping response and SHOULD NOT issue topic.* commands.

No changes to § 14.1 levels table or § 14.2 renderer classification.

2.6 Amendment to hub.ping — Capability advertisement

hub.ping is a pre-existing hub-internal command used for liveness checks and handshake timing. It is not defined in the display protocol spec proper because it operates below the ui.* layer, but its response shape is observable to any process that issues it. This amendment adds a single field to the hub.ping response body without changing its headers or semantics.

Change: The hub.ping response body, previously empty or containing implementation-defined liveness metadata, MUST now include an extensions map describing the hub's supported command families and their versions.

Response body addition:

{
  "extensions": {
    "core": "1.0",
    "topic": "1.0"
  }
}

Semantics:

  • core is always present and carries the hub's implemented version of the AMP display protocol spec (§ 0.1). v1 hubs report "1.0".
  • Each additional key names a hub extension and carries its version string. topic is the first such extension; future extensions (stream, presence, etc.) will appear alongside it.
  • Versions are independent per extension. A hub MAY support core: 1.0 and topic: 1.1 simultaneously. Clients SHOULD compare versions via semver semantics.
  • An extension's absence means the hub does not implement it. Clients MUST NOT assume an extension exists; they MUST check before issuing commands from that extension.
  • Additional keys beyond extensions MAY appear in the ping response for implementation-defined liveness data (latency hints, peer counts, etc.). Clients MUST ignore unknown keys per the general "ignore unknown headers/fields" rule.

Backwards compatibility: The extensions field is strictly additive. Pre-delta hubs that return an empty or implementation-defined body continue to work with clients that don't look at extensions. Clients that do look for extensions and find it absent MUST treat this as "core only, no extensions" — the safe-degradation default.

Rationale: Capability discovery via error-on-use (call topic.list, check for RC 10) is the kind of pattern that accretes startup-latency debugging cost as the extension set grows. Discovery belongs in the handshake. Versioning per-extension rather than a single monolithic spec version lets topic.* evolve independently of the core ui. protocol — important because the broker is likely to grow features (federation, claim-producer, wildcards) on a faster cadence than the stable ui. surface.

3. New § 3.11 — topic.* — Hub Topic Broker

The topic.* command family is a hub extension that provides named data channels with cached-latest semantics. It is separate from the ui.subscribe / ui.unsubscribe commands in §§ 3.9–3.10, which route filtered ui.event messages for behavior attachment. Topics carry arbitrary payloads, most commonly ui.batch messages for reactive panel updates; event filters carry structured events for script handlers.

3.11.1 Concepts

A topic is a flat string name identifying a data channel. Names are free-form UTF-8 with the exception that names beginning with $ are reserved for hub-internal use (e.g. $stats.publish_rate, $peers); publishes to reserved names from non-broker peers MUST be rejected with RC 10.

A snapshot is the most recent published payload for a topic, cached by the hub. Caching is latest-wins: each publish replaces the previous snapshot. Snapshots are opaque to the broker — the hub stores bytes and forwards bytes.

A subscription is a (topic name, subscriber peer) pair registered in the hub. When a topic is published to, the hub fans out the payload to every current subscriber. When a peer subscribes, the hub immediately replays the cached snapshot (if one exists).

A sequence number is a monotonically increasing per-topic u64 counter, incremented on each publish and attached to every delivery as the topic_seq header. Subscribers use it for gap detection and debugging multi-producer races.

Reserved header names. The broker injects the following headers into each delivery. Producers SHOULD NOT include these headers in publish payloads; if present, the broker silently overwrites them (RC 0, no error). Future versions MAY tighten this to outright rejection.

Header Purpose
topic Topic name this delivery came from.
topic_seq Monotonic per-topic sequence number.
topic_stale Present with value true if the cached snapshot's publisher has been marked stale per § 10.3.1.
topic_op Present with value delete on the final delivery after topic.clear with notify: true.

Per § 1.2, header names are case-sensitive and lowercase with underscores. The reservation covers only the exact lowercase forms listed above; mixed-case variants (Topic, TOPIC_SEQ) are not valid AMP headers at all and are rejected by the wire format itself. The broker MUST overwrite any reserved header present in an incoming publish payload. Future versions MAY tighten this to outright rejection (RC 10) if pre-seeding turns out to be a vector for anything.

Anonymous publisher identity. Processes that connect to the hub without registering a service name (the default for Mix scripts using connect_anonymous) are assigned a synthesized, connection-scoped identity at WebSocket establishment time:

anon-<hex_nonce>-<unix_seconds>

Example: anon-a4f8c2e1-1744243200. The hex nonce is a random u32 preventing collision among concurrent anonymous connections; the Unix timestamp makes log-grepping a dead producer trivial. This identity is stored in the peer record alongside the outbound channel and used as the last_publisher value for any TopicEntry the peer publishes to. It is visible in topic.list responses and in hub.tap stream frames.

Synthesized identities are hub-local: they are valid only on the hub that minted them, MUST NOT be federated across mesh bridges, and MUST NOT be persisted across hub restarts. A client that disconnects and reconnects receives a fresh identity; the hub treats the new connection as an unrelated publisher. Snapshot cleanup on disconnect proceeds through the existing reverse index — the synthesized identity is the key.

With this identity in place, topic.active / topic.idle notifications (§ 3.11.8) work for the full lifetime of a long-running anonymous producer. Notifications addressed to a dead connection's identity are dropped at the routing layer, not misdelivered to a later anonymous connection — the nonce makes cross-connection collisions impossible by construction.

3.11.2 topic.publish — Publish a snapshot

Replaces the topic's cached snapshot and fans the payload out to all current subscribers.

Direction: Process → hub Type: request (expects RC response)

Required headers:

Header Type Description
command "topic.publish" Command identifier
name string Topic name (must not begin with $ unless sent by a broker-internal identity)

Optional headers:

Header Type Default Description
retain bool true If false, the payload is fanned out to current subscribers but not cached. Use for fire-and-forget notifications that shouldn't replay to later subscribers.

Body: A complete, serialized AMP message (including its own --- header frame but without a trailing ---\nEOM\n terminator). The broker treats the body as opaque bytes. The most common payload is a ui.batch message (§ 3.8) carrying ui.update / ui.data sub-messages, but the body MAY be any AMP message.

Nesting constraint: The inner AMP message's body MUST NOT contain the sequence ---\nEOM\n, which would be interpreted as the outer message's terminator by the stream parser (01_amp-wire-protocol.md §5.2). In practice this means topic.publish payloads cannot themselves nest further AMP messages — nesting depth is limited to one level. This is sufficient for all v1 use cases (ui.batch bodies are JSON, not nested AMP).

Maximum payload size: 1 MiB (provisional). This bounds worst-case per-peer buffer memory: 256 (channel capacity) × 1 MiB = 256 MiB per slow subscriber. In practice, ui.batch payloads for reactive dashboards are well under 100 KiB. The limit may be tightened in future versions based on production data. Publishes exceeding this limit are rejected with RC 10 and an error body of {"error": "payload_too_large", "limit": 1048576}.

Response: RC 0 with {"seq": N, "delivered": M} where N is the new sequence number for this topic and M is the number of subscribers the payload was fanned out to. On error, RC 10 with {"error": "..."}.

Fan-out semantics: The broker parses the outer topic.publish wrapper, extracts the body (which is itself an AMP message), injects two routing headers into the inner message — topic: <name> and topic_seq: <seq> — and additionally topic_stale: true if the producer was marked stale per § 10.3.1. The annotated inner message is then sent to each subscriber through normal per-peer routing. The broker does NOT introduce a new command type; subscribers see the inner message's original command header (typically ui.batch) and dispatch through their existing handlers. The annotated inner message becomes the new cached snapshot (if retain is true).

retain: false edge cases. When retain is false and no subscribers exist, the publish is a no-op: the payload is neither cached nor delivered anywhere. The response still returns RC 0 with {"seq": N, "delivered": 0} so producers can detect the condition if they care. The sequence number is incremented on every publish regardless of retain — it counts publishes, not snapshots — so subscribers that join mid-stream and see a mix of retained and non-retained publishes can still use topic_seq for gap detection without special-casing retention state.

Join-after-non-retained publish: A subscriber that joins after the most recent publish was retain: false receives the most recent retained snapshot (if one exists), with its original topic_seq. If no retained snapshot exists, no replay occurs and the subscribe response returns seq: 0. The gap between the replayed snapshot's seq and the next delivery's seq reflects the non-retained publishes that occurred in between — this is expected and not an error condition.

Header injection is a security property, not an implementation convenience. The broker MUST inject the routing headers (topic, topic_seq, topic_stale, topic_op) after accepting the publish and allocating the sequence number — never by passing through producer-supplied values. A producer MUST NOT pre-seed these headers to influence ordering, staleness, or delivery framing; any such headers present in the incoming inner message are unconditionally overwritten by the broker's values. This matters today for debugging (a producer cannot fake sequence gaps to confuse gap-detection tooling) and matters tomorrow for cross-mesh federation (a peer on a federated topic cannot spoof sequence numbers to cause subscribers on other meshes to skip messages). Implementations MUST treat producer-supplied routing headers as adversarial input even though the v1 trust domain (WireGuard /24, 05_amp-display-protocol.md § 15.1) does not assume an active attacker — the property costs nothing to enforce and is load-bearing for future versions.

Example:

---
command: topic.publish
name: sysmon.metrics
---
---
command: ui.batch
---
[
  {"command": "ui.update", "headers": {"target": "sysmon"}, "body": "# cachyos\n\n**Load:** 0.88 0.93 0.90"},
  {"command": "ui.data", "headers": {"target": "proc-table"}, "body": "[{\"id\":\"1\",\"pid\":\"1234\",\"name\":\"cosmix-indexd\",\"cpu\":\"12.3\",\"mem\":\"4.1\",\"state\":\"S\"}]"},
  {"command": "ui.data", "headers": {"target": "disk-table"}, "body": "[]"}
---
EOM

Note: the inner message (ui.batch) does NOT have its own ---\nEOM\n terminator — the outer message's ---\nEOM\n terminates the entire publish. The inner --- delimiters (header frame) are body content of the outer message.

A subscriber of sysmon.metrics will receive the inner message with routing headers injected:

---
command: ui.batch
topic: sysmon.metrics
topic_seq: 42
---
[ ... same JSON body ... ]
---
EOM

The delivered message is a standalone AMP message (with its own ---\nEOM\n terminator) and dispatches through the existing ui.batch handler unchanged.

3.11.3 topic.subscribe — Subscribe to a topic

Registers the calling peer as a subscriber to the named topic. If a snapshot exists, the hub replays it to the caller immediately as a normal fan-out delivery (same format as § 3.11.2).

Direction: Process → hub Type: request

Required headers:

Header Type Description
command "topic.subscribe" Command identifier
name string Topic name

Body: empty.

Response: RC 0 with {"subscription_id": "...", "replayed": bool, "seq": N}. replayed is true if the caller received a snapshot replay; seq is the sequence number of the replayed snapshot, or 0 if none existed. On error (reserved-name violation, etc.), RC 10.

The subscription_id is a diagnostic token for logging and tooling (hub.tap stream frames include it). It has no operational use in v1 — subscribers do not need to reference it in subsequent commands. Future versions may use it for subscription-scoped features (e.g., delivery limits, per-subscription filters).

Idempotency: Subscriptions are keyed by (peer, topic). A peer that subscribes to a topic it is already subscribed to receives the existing subscription_id and no duplicate delivery is fanned out on subsequent publishes. The operation is idempotent. This matches the implicit assumption in § 3.1.2 that a ui.panel update with an unchanged subscribe header is a no-op, and collapses the ambiguity in subscriber_count — the returned value unambiguously counts distinct peers, not subscription records.

3.11.4 topic.unsubscribe — Unsubscribe from a topic

Removes the caller's subscription to the named topic.

Direction: Process → hub Type: request

Required headers:

Header Type Description
command "topic.unsubscribe" Command identifier
name string Topic name

Body: empty.

Response: RC 0. A peer that unsubscribes from a topic it was not subscribed to still receives RC 0 (idempotent).

3.11.5 topic.subscriber_count — Query subscriber count

Returns the number of current subscribers to a topic. Used by producers to back off when nobody is watching.

Direction: Process → hub Type: request

Required headers: command: topic.subscriber_count, name: <topic>.

Response: RC 0 with {"count": N}. Absent topics return {"count": 0}.

3.11.6 topic.list — List topics

Returns metadata for all topics currently known to the broker. Intended for debugging and tooling.

Direction: Process → hub Type: request

Required headers: command: topic.list.

Optional headers: prefix: <string> to filter by topic name prefix (simple string match, not a glob).

Response: RC 0 with a JSON array of topic records:

[
  {
    "name": "sysmon.metrics",
    "subscribers": 2,
    "has_snapshot": true,
    "snapshot_seq": 42,
    "snapshot_size": 1284,
    "last_publisher": "sysmon",
    "stale": false
  }
]

3.11.7 topic.clear — Explicit snapshot removal

Removes the cached snapshot for a topic. Used by producers at shutdown to prevent stale replay to future subscribers.

Direction: Process → hub Type: request

Required headers: command: topic.clear, name: <topic>.

Optional headers: notify (bool, default true) — if true, the broker fans out a final delivery to current subscribers with a topic_op: delete header and an empty body. If false, subscribers are not notified.

Response: RC 0. Clearing an absent topic is idempotent.

Consumer-side behavior: A subscriber receiving a delivery with topic_op: delete SHOULD treat it as "the producer has invalidated its state" but MUST NOT automatically remove any panels bound to the topic. The panel's current rendered state is preserved — the producer owns that decision, not the broker. Consumers that want to signal invalidation visually (e.g. dim the panel) may do so.

3.11.8 topic.active / topic.idle — Subscriber-count transitions (hub → producer)

These are hub-emitted notifications, not commands a process calls. The hub sends topic.active to the last-known publisher of a topic when the subscriber count transitions from 0 to >0, and topic.idle when it transitions from >0 to 0. The last-known publisher is determined by the from header of the most recent topic.publish; anonymous publishers do not receive these notifications.

Direction: Hub → process Type: notification (no response expected)

Headers: command: topic.active (or topic.idle), name: <topic>, subscribers: <N>.

Body: empty.

Producer behavior: Producers MAY ignore these messages. A producer that honors them can implement back-off: stop publishing on topic.idle, resume on topic.active. With the synthesized anonymous identity from § 3.11.1, a long-running anonymous Mix producer does receive these notifications for the full lifetime of its connection — the "anonymous producers get no notifications" limitation from the first draft of this delta no longer applies.

Racing producers are best-effort. Under the documented racing-producers caveat (§ 5), topic.active / topic.idle fire only to the single most recent publisher as tracked by TopicEntry.last_publisher. Other live publishers racing on the same topic will not be informed of subscriber-count changes and will continue publishing at full rate regardless of viewer presence. This is a deliberate v1 scope decision: tracking a set of recent publishers per topic is plumbing the racing-producer caveat itself is already asking users to avoid. Producers requiring reliable back-off under contention should wait for topic.claim_producer in a future version, at which point at most one publisher per topic is live by construction and this ambiguity disappears.

3.11.9 Error codes

Condition RC (v1) Error body
Reserved-name violation ($-prefix from non-broker identity) 10 {"error": "reserved_name"}
Payload exceeds 1 MiB 10 {"error": "payload_too_large", "limit": 1048576}
Malformed body (not a valid AMP message) 10 {"error": "malformed_payload"}
Producer-supplied reserved routing header (topic, topic_seq, topic_stale, topic_op) in publish body 0 (silent overwrite)
Unknown topic on topic.clear 0 (idempotent success)
Broker not supported on this hub 10 {"error": "topic_not_supported"}

Reserved future error string. When a future version tightens reserved-routing-header handling from silent overwrite to rejection (per § 3.11.2), the error body will be {"error": "reserved_header"}. Implementations MUST NOT reuse this error string for any other condition in v1, so that client-side tests asserting against the error surface remain forward-compatible.

4. Design rationale

4.1 Payload opacity and the three-reader model

The broker treats topic payloads as opaque bytes. This is the property that lets the "hub owns no application state" principle survive the amendment in § 2.1. But opacity has a second, subtler payoff specific to the AMP three-layer model (Principle 1 of § 0.3: plain text, machine-parseable, AI-comprehensible).

Because the payload is an AMP message and the broker doesn't touch it, the same bytes serve three different readers:

  1. Human tailing the topic (mix sub sysmon.metrics) sees the inner AMP message in its plain-text form and can grep / read it directly.
  2. Display service parses the inner message's command header and dispatches through its existing widget rendering path.
  3. AI process (e.g. a Claude instance subscribed to the topic via cosmix-mcp) reads the markdown body as native LLM context and can reason about system state.

No format translation, no separate "human view" vs "machine view," no schema bridges. MQTT retained messages are opaque bytes; NATS KV values are opaque bytes; Phoenix PubSub payloads are opaque Erlang terms. None of them have this property because none of them made the protocol itself three-reader by construction. This is the ARexx message-port-as-universal-substrate design paying off, and it is worth preserving even if a future optimization (e.g. binary payloads for throughput) might seem attractive.

4.2 Why not unify topic.subscribe with ui.subscribe?

ui.subscribe (§ 3.9) filters the ui.event stream by (source_panel, action) — a structured predicate over a specific message family. topic.subscribe addresses via flat topic name — a lookup in a named channel space with cached-latest semantics. Collapsing them into one wire primitive either makes the predicate form accommodate flat names as a degenerate case (awkward), or makes the flat-name form accommodate predicates (awkward in the other direction). Phoenix reached the same split: PubSub (topic broadcast) and Presence (stateful distribution) are separate APIs because the data models differ.

The two families share an implementation registry in the hub (one subscription table, one reverse index, one disconnect-cleanup path). On the wire they are distinct because their addressing semantics are genuinely different. Spec users see two primitives with two purposes; implementers see one registry with two projections.

4.3 Why cache in the hub instead of pulling from the producer?

The alternative is: the hub stores only "who produces topic X," and on subscribe, the hub asks the producer to re-send the latest. This keeps the hub stateless but:

  • Adds a round-trip on every subscribe.
  • Fails when the producer is briefly offline during a viewer's reconnect.
  • Cannot serve viewers that opened before any publisher was running.
  • Makes the stale=true edge case impossible to detect (stale vs. never-published are indistinguishable).

MQTT and NATS both cache retained/latest values in the broker for the same reasons. The amendment in § 2.1 acknowledges this is a routing-layer buffer, not application state.

4.4 Why bounded per-peer channels?

Unbounded mpsc::Sender buffers let a slow subscriber consume memory without bound. Under normal routing this is unlikely (point-to-point messages are bursty but bounded by sender rates); under topic fan-out it is much more likely (a producer at 10 Hz multiplied across N slow subscribers). Bounded channels with drop-oldest convert an unbounded failure mode into a lost-message one. Since the topic snapshot is the source of truth, a subscriber that missed intermediate deliveries can catch up by re-reading the cache on reconnect; no data is permanently lost. MQTT QoS 0 drops on backpressure. NATS detects slow consumers and forcibly disconnects them. This plan does the latter: drop-oldest until a per-peer drop count threshold, then force-disconnect with an RC 20 "slow consumer" tap event.

5. Non-goals for v1

Each of these is additive to the wire format and can be introduced in a later version without breaking v1 consumers:

  • Cross-mesh topic federation. A topic published on node A is not automatically visible on node B. Topic federation will reuse the existing mesh bridge in noded when added.
  • Per-widget topic binding. The subscribe header on ui.panel binds an entire panel to one topic. Future versions may add a widget-level subscribe attribute for widgets that want to consume different topics within one panel.
  • Manifest-driven producer auto-spawn. In v1, the user runs producer scripts manually. A later version may add a topic manifest in settings.toml mapping topic names to launcher commands so the hub can spawn a producer on first subscribe.
  • Topic ACLs. The WireGuard /24 trust domain (05_amp-display-protocol.md § 15.1) covers v1. ACLs on topics (who may publish, who may subscribe) will be added when the first multi-tenant use case arrives.
  • Versioned / append-only streams. Snapshots are latest-wins. Topics with history (event sourcing, audit logs) will be a separate primitive (stream.*, TBD) rather than a generalization of topics.
  • Claim-single-producer lock. Two producers publishing to the same topic race; last-write-wins on the snapshot. The sequence number makes the race debuggable but does not prevent it. A topic.claim_producer lock may be added later.
  • Wildcards in subscriptions. Subscribers name one topic. No sysmon.* wildcards in v1.
  • Snapshot persistence across hub restart. Snapshots live in memory only. A hub restart clears them. Consumers re-subscribe and wait for the next publish.

6. Implementation phasing

This section is informative — it is not part of the spec but is included so implementers can scope the work.

Phase A0 — Bounded per-peer channels (hygiene) - Convert hub.rs peer outbound channels from UnboundedSender<String> to a bounded channel (capacity 256) with drop-oldest + slow-consumer disconnect. - Surface per-peer drop counters via hub.tap. - Regression-test existing routing under the new bounds. - No protocol changes.

Phase A — Broker core - New subscription.rs module in cosmix-noded with a SubscriptionBroker struct holding a single registry usable by both topic.* and the future ui.subscribe implementation. - topic.* command handlers per § 3.11. - Snapshot cache with 1 MiB cap, sequence numbers, $-prefix reservation, topic.clear support, and the stale-producer grace period from § 10.3.1. - Hub-emitted topic.active / topic.idle on subscriber-count transitions. - Stub ui.subscribe / ui.unsubscribe that write into the shared registry but return RC 5 (warning) with a body noting that event routing is not yet wired. This validates the shared-registry claim and reserves the command names. - Unit tests covering: publish/subscribe/replay, unsubscribe, disconnect cleanup, sequence monotonicity, snapshot cap, reserved-name rejection, clear + notify, stale flag transitions, multi-subscriber fan-out. - Manual verification via wsamp: no consumer code required.

Phase B — lib-display protocol types - PanelProps.subscribe: Option<String> field and parser. - No new command types required on the consumer side (the whole point of § 3.11.2's fan-out semantics).

Phase C — deskd subscription lifecycle - topic_bindings: HashMap<PanelId, String> in App. - handle_panel auto-subscribes on subscribe header per § 3.1.2. - WindowEvent::CloseRequested, ui.remove, TTL expiry, and orphan timeout all auto-unsubscribe. - Incoming ui.batch (or any command) with a topic header is dispatched normally; the topic header may optionally be used to verify the binding is still active and drop late deliveries for unbound panels.

Phase D — sysmon.mix rewrite - Extract gather logic into a function. - Open panel once with subscribe: sysmon.metrics. - Loop: build a ui.batch body, publish via topic.publish name=sysmon.metrics, sleep 2s. - Running the same script twice produces two racing producers; documented as a known v1 caveat.

7. Resolutions to initial open questions

The first draft of this delta flagged three open questions. All three are resolved in the body above; this section records the decisions and their rationales so future readers can reconstruct why the wire looks the way it does.

Header naming: kept unprefixed, reserved by name. The reserved headers topic, topic_seq, topic_stale, topic_op live in the general AMP header namespace rather than behind a prefix like x_topic_*. Resolution in § 3.11.1. Rationale: RFC 6648 retired the X- convention in HTTP precisely because it created a permanent split between "real" and "experimental" headers that outlived the experiment — once X-Forwarded-For became universal, the X- told you nothing useful and couldn't be removed without breaking the internet. AMP is young enough to learn this for free. Case-sensitivity is not a dodge: per § 1.2 headers are lowercase-with-underscores at the wire level, so Topic and TOPIC_SEQ are not valid AMP headers at all and need no separate reservation. The reservation covers only the exact lowercase forms. Future versions MAY tighten overwrite-on-collision to reject-with-RC-10 if pre-seeding turns out to be a problem vector.

Anonymous publisher identity: synthesized per connection. Anonymous publishers receive a anon-<hex_nonce>-<unix_seconds> identity at connection time. Resolution in § 3.11.1. Rationale: the "anonymous means no notifications, full stop" alternative kills the topic.active / topic.idle back-off mechanism for the 90% case (Mix scripts running for hours or days are the common producer shape), defeating the purpose of wiring it up as a no-op in v1. The connection-scoped nonce makes cross-connection misdelivery impossible by construction — a notification addressed to a dead connection's identity is dropped at the routing layer, not misrouted to a later anonymous connection that happens to connect to the same topic. Identities are hub-local by explicit mandate: they MUST NOT be federated or persisted, which keeps the failure mode "brief producer restart loses back-off context" rather than "synthesized identity somehow leaks between meshes."

Hub-extension discovery: via hub.ping extensions map. Capability discovery is advertised in the hub.ping response as a versioned extensions map ({"core": "1.0", "topic": "1.0"}). Resolution in § 2.6. Rationale: error-on-use discovery accretes startup-latency debugging cost ("why does the first publish take 80ms? oh, we're probing for broker support"). Discovery belongs in the handshake. Versioning per-extension rather than a single monolithic spec version lets topic.* evolve on a faster cadence than the stable ui.* surface — important because the broker is the place features will grow first (federation, claim-producer, wildcards). The extensions field is strictly additive to hub.ping: pre-delta hubs keep working, pre-delta clients keep working, and new clients reading a missing field fall back to "core only, no extensions" — the safe-degradation default per Principle 5 of § 0.3.

No open questions remain. Phase A0 (bounded channels in hub.rs) is cleared to start.

4 Scripting
Chapter 4

Mix Language Reference

version 0.3.0status stabledate 2026-04-13

Mix Language Reference

Mix is a scripting language designed for systems work — filesystem operations, process management, HTTP, structured data handling — with first-class AMP mesh messaging when running in a cosmix environment. In non-cosmix environments, AMP operations (send, emit, address, on) raise a clear runtime error; the rest of the language works identically. The amp_available() builtin lets scripts detect the environment and branch when needed.

The language is defined by its reference implementation in cosmix-lib-mix. There is one interpreter, shippable standalone as mix or compiled into cosmix binaries. This chapter documents what that implementation actually accepts, not an abstract specification. When this document and the source disagree, the source wins.

Source of truth: src/crates/cosmix-lib-mix/src/{lexer,parser,ast,evaluator}.rs. Doc claims cite file.rs:line for any non-obvious behaviour.

Scope: This chapter covers the Mix language itself — syntax, operators, control flow, scoping, and patterns. The standard library (currently 115 builtins in builtins.rs) is documented inline where relevant but a comprehensive builtin reference is planned as Chapter 04a. AMP-specific keywords are documented in §AMP below; everything else is available in any environment.

Quick reference for writing Mix scripts correctly on the first try. Read this before writing new Mix code — especially if you're coming from ARexx, Lua, or shell, because Mix differs from all three in ways that are easy to get wrong.

v0.2.3 shipped — v0.2.x series complete. All six phases of the stdlib-and-ergonomics plan have landed: anonymous functions + HOF list helpers (Phases 1–2), fmt/printf (Phase 3), recursive glob/walk/path_parts (Phase 4), terminator unification — end everywhere (Phase 5), and JSONL helpers read_lines/read_json/ read_jsonl (Phase 6). Deferred to v0.3: destructuring assignment, lexical closures converging named-and-anonymous semantics, catchable exit, two-registry HOF unification.

Quick gotcha list

Top-of-doc shortlist. If you only read this section, you'll avoid the common traps:

  1. Concatenation is .. (not ||). || is the statement-chain operator (run-if-failure), same as shell. String concat in expressions uses ...
  2. Command-line args are positional: $1, $2, ... (ARexx/shell style). There is no upper bound$1 through $N for as many args as the script was invoked with. args() returns the same args as a list (use it when you need length() or iteration).
  3. on blocks use on event.name (unquoted) and close with end. Example: on dbview.page ... end. on is statement-position only — it registers a global handler and is not legal inside function bodies.
  4. send/emit/address are ordinary statements — they work at script top level, inside on handlers, and inside function bodies. Reuse via library helpers (e.g. src/apps/lib/ui.mix) is a style choice, not a requirement. (Earlier doc revisions claimed a top-level restriction; that was never enforced by the parser or evaluator. See evaluator.rs:1074–1088 and call_function at 1644.)
  5. String interpolation does NOT recurse. If $var = "hello" and $template = "${var}", then "${template}" produces the literal string ${var}, not hello. This matters when passing complex strings through heredocs.
  6. Heredoc interpolation expands ${var} eagerly — one pass only.
  7. $var.field dot access in strings walks the full chain: "host: ${cfg.host}" and "timeout: ${cfg.db.opts.timeout}" both resolve identically to their expression-position counterparts ($cfg.host, $cfg.db.opts.timeout). Each step honours the Value::Map * fallback. Mid-chain non-map values yield nil. (Shipped in Phase 0 of v0.2.0; earlier v0.1.0 revisions only honoured the first dot.)
  8. Comparison operators have asymmetric coercion. ==/!= cross-type-coerce string↔number via parse-as-f64 (value.rs:79–96), so "5" == 5 is true. But </>/<=/>= route through num_cmp (evaluator.rs:2022) which errors on any value that doesn't parse as a number — including nil. So nil > -1 is a runtime error, not false. eq/ne are strict string comparisons with no coercion.
  9. Keywords and/or for expressions, &&/|| for statement chaining. They are NOT interchangeable.
  10. All blocks close with end (since v0.2.2). Legacy done/next still parse with deprecation warnings — removal planned for v0.3. See table below.
  11. Event fields live in $event["headers"], not top-level $event. $event has only three keys: command, headers, body.
  12. Value::Map field access falls back to the * key. $cfg.host returns the map's "*" value if "host" isn't set (evaluator.rs:1547). This is a deliberate defaults pattern — set $cfg.* = "default" and unknown lookups return that. Surprising the first time you see it.

Block terminators

v0.2.2: every block closes with end. The legacy done / next forms for while / loop / for / for each / on still parse but emit a deprecation warning to stderr. Plan to remove the legacy forms one release cycle from now (target: v0.3).

Block Terminator
if...then...end end
for...to end
for each...in end
while end
loop end
function end
try...catch end
select...when end
address end
on end

Migration: new scripts should use end universally. Existing scripts will keep working but should be migrated when next touched. A Mix script that does the line-level rewrite is in the tree:

mix _bin/mix_migrate_terminators.mix path/to/script.mix [...]

The migration script only rewrites bare done / next lines (trimmed content equals the keyword) — it won't touch keywords in strings or mid-line comments. Always run under version control so you can diff the result.

Lexical elements

Variables

  • $name — identifier starting with $, then [a-zA-Z_][a-zA-Z0-9_]*
  • $1..$9 — positional command-line arguments. Return nil when not provided.
  • $_ — result of pipe LHS in pipe expressions
  • $event — only available inside on handlers, read-only, map with command/headers/body keys
  • $rc — return code from most recent AMP send statement

String literals

Single-quoted '...'no interpolation. Escapes: \' and \\ only.

$literal = 'This is ${not} interpolated'

Double-quoted "..." — interpolation enabled.

$greeting = "Hello, ${name}!"
$cmd_output = "Today is $(date +%Y-%m-%d)"

Escapes: \n \t \r \e \" \\ \$ (\$ produces literal $, preventing interpolation).

Heredoc <<TAG ... TAG — multiline, interpolation enabled.

$body = <<MD
# Title

Content line with ${variable} interpolated.
MD

Closing tag must be on its own line, exact match. Trailing newline is removed automatically.

Comments

Both # and -- run to end of line. No block comments.

Keywords (case-sensitive)

Control: if then else end for each in to step next while done loop break continue
Functions: function return
Case: select when otherwise
Logic: and or not true false nil
Parse: parse with
AMP: send address emit on
Errors: try catch die
Env: export alias source sh
I/O: print eprint

Operators

Precedence low to high (parser.rs:908-947):

Prec Op Name Notes
1 or Logical OR Short-circuit
2 and Logical AND Short-circuit
3 == != < > <= >= Comparison Cross-type coerce
3 eq ne String comparison No coercion
4 ?? Nil coalesce x ?? y — y if x is nil
5 .. Concatenation String concat, both sides coerced
6 + - Add / subtract + also concats if either side isn't numeric
7 * / % Multiply / divide / modulo Numeric only
8 ** Power Right-associative

Unary: - (negation), not or ! (logical NOT).

Statement-level chain operators (NOT expression operators):

  • stmt && other — run other only if $rc == 0 after stmt
  • stmt || other — run other only if $rc != 0 after stmt
  • stmt | external_cmd — pipe stmt stdout to external command

These are the operators that caused dbview bugs: || is run-if-failure (like shell), not concat. Use .. for string concatenation.

Statements

Assignment

$var = expression
$obj.field = value
$arr[0] = value
$obj.* = value      -- sets all fields of a map

Conditionals

if condition then
    body
else if other_cond then
    body
else
    body
end
select $value
when "a" then
    body
when "b" then
    body
otherwise
    default_body
end

Loops

for $i = 1 to 10 step 2
    print $i
next
for each $item in $list
    print $item
next

for each $i, $item in $list   -- with index
    print $i, $item
next
while $condition
    body
done

loop
    body
    break if $done
done

break [label] [if cond] and continue [label] [if cond] support optional conditions and loop labels.

Functions

function name($arg1, $arg2)
    body
    return $value
end

Expression form:

function double($x) = $x * 2

Parameters can have defaults: function greet($name, $greeting = "Hello").

Anonymous functions (v0.2.0)

function is also valid in expression position with no name, producing a first-class function value that can be stored in a variable, passed as an argument, or returned from another function. Both body forms work:

$double = function($x) = $x * 2
print $double(5)                        -- 10

$inc = function($x)
    return $x + 1
end
print $inc(4)                           -- 5

type($double) returns "function". Functions compare as never-equal (identity comparison at the Mix level is deliberately disabled — it's rarely useful and tends to encourage bugs).

Calling a function value uses the same $var(args) syntax as calling a named function, but dispatched through the value in scope rather than a bareword function name:

-- $sort_by is the function VALUE in a variable (whatever that means
-- for your script — maybe built by a factory). Dispatches via scope.
$sort_by($items, $key_fn)

Bareword calls like sort_by($items, $key_fn) still dispatch through the builtin / user-function tables as before.

Closure semantics (v0.2.0)

Two-tier rule:

Top-level lambdas (constructed at script top level) see globals live at call time. Mutating a global after capture is visible to the lambda:

$t = 5
$gt = function($x) = $x > $t
$t = 100
print $gt(50)                           -- false (uses current $t, not 5)

Inner-frame lambdas (constructed inside a named function) see a frozen snapshot of the enclosing function's frame. This is capture-by-value, taken at the moment the function(...) expression evaluates. It's how closures over a caller's parameters work:

function make_adder($n)
    return function($x) = $x + $n    -- $n captured by value
end

$plus40 = make_adder(40)
print $plus40(2)                        -- 42 (even though make_adder has returned)

The divergence between "top-level = live" and "inner-frame = frozen" is acknowledged and narrower than the v0.1.0 state (where inner lambdas saw nothing useful at all). v0.3 will converge both named and anonymous functions on a single rule; until then, inner-frame lambdas are capture-by-value and cannot observe later mutations to the captured frame.

Named functions continue to see globals live and cannot capture enclosing function locals — the captures slot is only populated for anonymous function(...) expressions.

Event handlers (on)

on hub.ping
    print "received:", $event["body"]
done

on dbview.page
    $page = to_number($event["page"])
    -- handle page change
done
  • No quotes around the event name. Dotted identifiers work: dbview.page, hub.ping.
  • Closes with done, not end.
  • $event is a map with exactly three keys:
  • $event["command"] → the full command string (e.g. "dbview.sort")
  • $event["headers"] → map of AMP headers. All user-defined event fields live here (e.g. $event["headers"]["column"])
  • $event["body"] → raw body string (if any)
  • Event fields are in headers, not top-level. A common mistake: $event["column"] is nil. The correct access is $event["headers"]["column"], or destructure first: $h = $event["headers"] then $h["column"].
  • Handlers serialize — only one runs at a time. Slow handlers block the event loop.

AMP messaging (send, emit, address)

-- Statement form
send "target" command.name arg1 arg2 key=value

-- Expression form: assign result
$result = send "hub" hub.ping

-- Fire-and-forget (no $rc)
emit "display" ui.panel id="main" body=$content

-- Multiple sends to same target
address "display"
    ui.status target="main" body="Ready"
    ui.data target="list" body=$json
end

send/emit/address are ordinary statements and work in any statement position, including inside function bodies. Wrapping them in helpers is a style choice for reuse, not a workaround for a parser restriction:

-- This is fine — define and call a helper directly.
function panel($id, $title, $body)
    emit "display" ui.panel id=$id title=$title body=$body
end

panel("main", "My Panel", $body)

Library files in src/apps/lib/ exist to share these helpers across scripts via source, not because top-level is required.

Two real constraints to remember:

  • on IS statement-position only — it registers a global event handler and is not legal inside a function body. Define handlers at script top level.
  • Closures do not capture enclosing function frames (v0.1.0). A function defined inside another function sees globals + its own params only — not the outer function's locals. v0.2.0 will likely add capture-by-value at construction time for inner-frame lambdas; named functions may keep live-binding lookup. Plan tracks this.

Error handling

try
    $result = risky_op()
catch $err
    print "failed:", $err
end

die "fatal: " .. $reason

Parse statement

parse "host:port" with $host ":" $port

Captures $host = "host" and $port = "port" using literal delimiters.

Source, sh, export, alias

source "path/to/lib.mix"              -- run another mix file in current scope
sh "ls -la /tmp"                       -- statement form, inherits stdio
$output = sh "ls -la /tmp"             -- expression form, captures stdout
export PATH = "/usr/local/bin:${PATH}"
alias ll = "ls -la"

Expressions

Literals

  • Numbers: 42, 3.14, 1_000_000 (underscores as separators), always f64
  • Strings: 'single', "double", <<HEREDOC...HEREDOC
  • Booleans: true, false
  • Nil: nil
  • Lists: [1, 2, 3], ["a", "b"] — trailing comma allowed
  • Maps: {host: "localhost", port: 8080} — keys are bare identifiers or strings, colon separator

Function calls

length($list)
sqlexec($db, $sql, [$id])
$list.length()          -- method form, desugars to length($list)
func()[0]               -- postfix chaining

Field / index access

$config.host
$config["host"]
$list[0]
$list[$i + 1]
$list[-1]                -- negative indices count from the end (v0.2.0)
$s[-1]                   -- same for strings (unicode-safe, returns a char)

Out-of-bounds index returns nil silently — index expressions never raise. Use length($xs) if you need to check bounds explicitly.

Send / sh in expression position

$info = send "hub" hub.info
$files = sh "ls /tmp"

Command substitution in strings

"today is $(date +%F)"
$result = $(whoami)

Balanced parens are tracked, so $(echo (foo)) works.

String interpolation details

Variable expansion in double-quoted strings and heredocs:

  • ${name} — variable lookup
  • ${cfg.host} — single-level dot access for maps
  • ${cfg.db.opts.timeout} — chained dot access; walks the full path. Each step looks the field up against the current Value::Map, falling back to the * key if present (evaluator.rs Expr::InterpolatedString arm). A non-map intermediate value yields nil for the rest of the chain. Resolves identically to expression-position $cfg.db.opts.timeout.
  • $(cmd) — command substitution
  • \$ — literal $ (prevents interpolation)
  • Undefined variables interpolate as nil

No recursive expansion. This is the trap:

$var = "hello"
$template = "${var} world"
$result = "${template}"      -- prints "${var} world", not "hello world"

Interpolation happens once at expression evaluation time. Once a string is stored in a variable, its content is literal text — even if that text contains ${...}, it won't expand when re-interpolated.

Practical implication: when building markdown bodies with JSON inside, prefer building the full string with .. concatenation over nesting variables in heredocs. The heredoc form works if all variables are simple scalars, but it's easy to forget one case and produce malformed output.

Truthiness

  • Falsy: nil, false, "", 0, "0", empty list [], empty map {}
  • Truthy: everything else

Command-line arguments

-- Positional (ARexx/shell style)
$cmd = $1
$arg = $2
if $1 == nil then
    print "usage: script <cmd>"
    exit(1)
end

-- Or as a list
$argv = args()
print length($argv), "arguments"

args() skips the binary name and script path — it returns just the user-provided args.

No cap on positional indexes. $1 through $N work for any N the script was invoked with. The lexer accepts $<digits> (lexer.rs:595–619) and main.rs:64 binds every script arg as (i+1).to_string(). Out-of-range positions return nil, not an error (evaluator.rs:1192–1195).

Common patterns

Building markdown for a widget with JSON content

Use .. concatenation when the value contains ${}-like characters (JSON):

$col_defs = '[{"key":"name","label":"Name"}]'
$dt = "~~~datatable id=data page_size=" .. $page_size .. " total_rows=" .. $total
$body = "# " .. $title .. "\n\n" .. $dt .. "\ncolumns: " .. $col_defs .. "\n~~~"

Loading a library

-- Resolve absolute path so scripts work from any CWD
$_cos = env("COSMIX_SRC")
if $_cos == "" then
    $_cos = env("HOME") .. "/.cos"
end
source "${_cos}/src/apps/lib/ui.mix"

Event loop keep-alive

-- At end of a GUI script, sleep forever; on handlers still fire
sleep(86400)

Functional list helpers (v0.2.0)

All 12 higher-order helpers take a function value as their last argument. Lambdas are the idiomatic source:

-- Transform
$doubled = map($xs, function($x) = $x * 2)
$odds = filter($xs, function($x) = $x % 2 == 1)
$sum = reduce($xs, 0, function($acc, $x) = $acc + $x)

-- Sort. Key function returns the sort key (number or string).
-- Descending: negate a numeric key, or build a reversed string.
$by_score_desc = sort_by($users, function($u) = 0 - $u.score)

-- Top-N: chain sort_by with slicing
$top10 = take(sort_by($users, function($u) = 0 - $u.score), 10)

-- Folds
print any($xs, function($x) = $x > 100)
print all($xs, function($x) = $x > 0)
print count($xs, function($x) = $x % 2 == 0)

-- _by family returns the ITEM, not the key
$worst = min_by($users, function($u) = $u.score)
$total = sum_by($users, function($u) = $u.score)

-- Grouping / dedup
$by_role = group_by($users, function($u) = $u.role)
$by_email = unique_by($users, function($u) = $u.email)

Pure list helpers (no function argument):

slice($xs, 1, 4)         -- sublist [1, 4) — end exclusive
slice($xs, -3, nil)      -- last three — nil end means "to end"
take($xs, 5)             -- first 5 (or last 5 with take($xs, -5))
drop($xs, 5)             -- skip first 5 (or drop last 5 with drop($xs, -5))
zip($keys, $vals)        -- list of [k, v] pairs

slice, take, drop all clamp on out-of-range boundaries rather than erroring — slice($xs, -100, 100) on a 3-element list returns the whole list. slice also works on strings (unicode-safe).

Reading structured files (v0.2.3)

Three helpers avoid the read_file + parse pattern scripts were writing by hand:

-- Line-oriented text: trailing newline stripped, empty trailing
-- line dropped so callers don't filter it out.
$lines = read_lines("/var/log/auth.log")
print length($lines), "lines"

-- Single-record JSON file → Mix value directly
$cfg = read_json("/etc/cosmix/config.json")
print $cfg.listen

-- JSON-lines (jsonl): one record per line → list
-- Strict by default: a single malformed line aborts the read.
$events = read_jsonl("/srv/mail/msg/alice/.spamlite-stats.jsonl")
print length($events)

-- Lenient mode: silently skip malformed lines. Use when log rotation
-- can leave a truncated tail and you'd rather lose one record than
-- fail the whole aggregation.
$clean = read_jsonl("/srv/rotating/events.jsonl", {skip_errors: true})

Strict-by-default for read_jsonl is deliberate: the canonical use case is aggregating stats across dozens of files, and silently dropping bad lines would make aggregate numbers unreliable without telling the script author. Lenient mode is the explicit escape hatch for known-noisy inputs.

Filesystem ergonomics (v0.2.1)

Recursive glob, walk, and path_parts:

-- Single-component (unchanged from v0.1.0)
glob("*.txt")                           -- files in CWD
glob("/var/log/*.gz")                   -- one wildcard component

-- Multi-component and recursive (v0.2.1)
glob("/srv/*/msg/*/.spamlite-stats.jsonl")   -- * in any component
glob("src/**/*.rs")                           -- ** = zero-or-more dirs

-- Enumerate every file under a tree
walk("/srv/mail")                       -- files only, sorted
walk("/srv/mail", {max_depth: 2})       -- cap depth (0 = direct children)
walk("/srv/mail", {include_dirs: true}) -- add dirs to the list
walk("/srv/mail", {follow_symlinks: true})  -- with loop protection

-- Decompose a path
path_parts("/home/user/report.tar.gz")
-- → {dir: "/home/user", base: "report.tar.gz", stem: "report.tar", ext: "gz"}

glob rules: - * and ? work in any path component, not just the last. - ** matches zero or more directory levels (think shell globstar). - Leading / → absolute pattern; otherwise relative to CWD. - Results are sorted lexicographically and ./foo normalizes to foo. - Nonexistent intermediate components silently return an empty list.

walk rules: - Returns a flat list of paths, sorted lexicographically. - Unreadable subdirectories are skipped silently — a single bad file in a deep tree doesn't abort the walk. The top-level $dir errors only if it doesn't exist. - max_depth: 0 means "direct children only, don't descend." None (omit the key) means unlimited. - Symlink loop protection only activates with follow_symlinks: true; uses (dev, inode) tracking on Unix.

path_parts rules: - Pure, no filesystem access. Works on nonexistent paths. - ext is WITHOUT the leading dot. extname keeps the dot — they're different consumers (ext for comparison, extname for reconstruction). - stem is everything before the last dot: path_parts("foo.tar.gz").stem returns "foo.tar", not "foo".

Formatted output (v0.2.0)

Three functions share the same minimal format grammar:

fmt("hello %s", "world")         -- returns "hello world"
printf("count=%d\n", 42)         -- writes to stdout, returns nil
eprintf("error: %s\n", $msg)     -- writes to stderr, returns nil
Spec Meaning
%s any value via to_mix_string
%d integer (truncates floats)
%f float, default 6 decimals
%.Nf float, N decimals
%Nd integer, min-width N (right-aligned)
%Ns string, min-width N (right-aligned)
%-Ns string, min-width N (left-aligned)
%% literal %

No {}-style templates — Mix already has ${...} interpolation; a second template syntax would confuse scripts. No %x/%o/%e/%g — add them if a real script needs them.

Unknown specifiers and too-few-args errors raise RuntimeError rather than substituting silently, so typos surface loudly. printf does not add a trailing newline — include \n explicitly, like C.

The canonical tabular-output pattern:

for each $u in $users
    printf("%-12s %5d %5d\n", $u.name, $u.fn, $u.fp)
next

.. concatenation already coerces every value through to_mix_string (so "count=" .. 5 works), which means printf is sugar for readability rather than an escape from a broken behaviour. Prefer printf for tabular output where alignment matters; prefer ${} interpolation or .. concat for simple string building.

Differences from ARexx / Lua / shell

From ARexx

  • Concat is .., not ||. || is the run-if-failure chain operator.
  • Variables need $ prefix. Bare identifiers are function names or keywords.
  • Strings don't auto-concatenate: "a" "b" is a parse error; use "a" .. "b".

From Lua

  • Maps use {key: value} with colon (not =).
  • No metatables, no local scope (all vars in current function frame).
  • end closes every block (since v0.2.2). Legacy done/next still parse with deprecation warnings; removal planned for v0.3.

From shell

  • $var in expressions is OK; ${var} only works inside strings.
  • No glob expansion: *.txt is literal. Use glob("*.txt").
  • No word splitting: "a b c" is one string, not three. Use split(x, " ").
  • &&/||/| are statement-level only.
  • Errors don't abort the script — use try/catch or die.

Mix Outside Cosmix

Mix works as a standalone shell and scripting language without the cosmix stack. The interpreter detects at startup whether an AMP hub is available (via COSMIX_HUB env var or local socket probe). The detection result is stored in the interpreter context and exposed via amp_available().

Environment detection

if amp_available() then
    send "maild" "account.list"
else
    print "No AMP hub — reading from file"
    $accounts = read_file("/tmp/accounts.json")
end

AMP keyword behaviour without a hub

Keyword Behaviour Error message
send Runtime error send: no AMP hub available
emit Runtime error emit: no AMP hub available
address Runtime error address: no AMP hub available
on Runtime error on: no AMP hub available

Scripts that don't use AMP keywords run identically in both environments. The 115 builtins (filesystem, HTTP, JSON, regex, crypto, process management) are all available without a hub.

The ARexx precedent

ARexx was the Amiga's scripting language but also worked as a general-purpose language — standalone REXX scripts that did nothing Amiga-specific ran fine. The ARexx port system was the standout feature, but the language existed in both contexts. Mix follows the same model: a useful shell on its own, with mesh messaging as a capability that activates when a hub is present.

Distribution

The mix binary is a single statically-linked Rust executable. It can be distributed independently of the cosmix stack. The AMP wire-format library (cosmix-lib-amp) is linked but dormant without a hub connection. Binary size overhead for the AMP code is minimal (~1000 lines of Rust).

Known sharp edges

  1. Heredoc + JSON / ${...} content: if the value contains literal ${...} sequences that should not expand, prefer explicit .. concat. Heredocs interpolate eagerly and there's no opt-out per-segment.
  2. Comparison asymmetry: == cross-type-coerces (so "5" == 5 is true), but </> error on coerce failure (so nil > -1 raises a runtime error rather than returning false). This is value.rs:79 vs evaluator.rs:2022 — two code paths, two rules. If you might compare nil, guard with ?? or an explicit if $x == nil.
  3. Mid-chain non-map in interpolation yields nil: ${cfg.host.foo} returns nil if $cfg.host is a string, not an error. Same rule as expression-position field access. The chain walks as far as it can on Value::Map, then short-circuits to nil.
  4. Value::Map falls back to *: $cfg.unknown_key returns the value of $cfg.* if set. Convenient as a defaults pattern, surprising if you forgot you set *.
  5. exit(N) is not catchable: builtin_exit (builtins.rs:745) calls std::process::exit directly. It bypasses try/catch, skips Rust-side cleanup, and kills the host process if Mix is embedded. Use die "msg" (catchable, raises MixError::DieError) when you want intercept-able termination. v0.3 will convert exit to a catchable error variant.
  6. Named functions vs anonymous lambdas capture differently (v0.2.0): Named function foo(...) definitions still see globals + their own params only — they do not capture an enclosing function's locals. Anonymous function(...) expressions built inside a named function capture that frame by value (frozen snapshot). Top-level $f = function(...) sees globals live — no capture needed. The divergence is deliberate for v0.2.0 and is acknowledged in the plan's Q2. v0.3 will converge both forms on a single lexical-closure rule.
  7. args() vs $N: both work, but $1 is idiomatic for command scripts. args() is better when you need length() or iteration.

See also

  • src/crates/cosmix-lib-mix/src/parser.rs — authoritative grammar
  • src/crates/cosmix-lib-mix/src/builtins.rs — all 115 builtins (future Ch 04a)
  • src/apps/sshm.mix — complete example of a Mix GUI app
  • src/apps/lib/ui.mix — library of panel/data/status helpers
  • mix/dbview — SQLite viewer using the datatable features
  • src/_doc/2026-03-29-mix-language-plan.md — original design doc (historical)
  • src/_doc/2026-04-11-mix-structured-pipelines.md — planned pipeline syntax (historical)
5 Display
Chapter 5

AMP Display Protocol Specification

version 0.3.0status draftdate 2026-04-06

AMP Display Protocol Specification


0. Preamble

0.1 Scope

This specification defines the AMP Display Protocol — the rules by which processes declare user interfaces via AMP messages and display services render them. It covers message formats, command vocabulary, content mapping, property semantics, scripted behavior, widget types, composition, theming, transport tiers, and security.

A conformant display service (renderer) can be implemented from this document alone. A conformant process (UI producer) can generate valid display messages from this document alone.

0.2 The Three-Layer Model

The AMP Display Protocol separates UI into three orthogonal layers, each carried as plain text within the same AMP message:

Layer Carries Analogy Format
Markdown Content + structure HTML Message body (GFM)
AMP headers Properties + layout CSS Message frontmatter (key: value)
Mix scripts Behavior + logic JavaScript ~~~mix code blocks or script header

All three are plain text. All three travel over the same AMP transport (Unix socket, WebSocket, SMTP). The display service receives one AMP message and extracts all three layers from it.

Unlike the web platform, where HTML, CSS, and JavaScript evolved independently over 30 years with different authors, transport mechanisms, and mental models, these three layers are designed together on a unified message format. There is no loading order, no cascade, no cross-origin policy — one message, three layers, one parse.

0.3 Design Principles

  1. Plain text everywhere. Every message is human-readable (cat, grep, markdown viewers work directly). Every message is machine-parseable (deterministic header extraction). Every message is AI-comprehensible (markdown is native LLM format).

  2. Protocol over framework. The protocol defines how UI is declared. Renderers are swappable implementations. Apps are processes that speak the protocol, not binaries linked against a widget library.

  3. Fixed widget set. The 22 widget types defined in this spec are the complete vocabulary. No runtime extensibility, no plugin widgets, no code shipping. New widget types require a protocol version bump.

  4. Headers route, bodies reason. Routers parse only headers. Display services parse headers + body. Scripts reason over events. The same message serves all three readers at different depths.

  5. Graceful degradation. A ui.panel message renders as a native window on a desktop, as HTML in a browser, as styled text in a terminal, and as readable plain text in a non-cosmix email client. Fidelity varies; readability is preserved.

  6. AMP at every app-facing boundary. All communication that applications send or receive uses AMP — text, human-readable, language-agnostic. Internal display infrastructure (widget-to-widget within the same panel, shell↔deskd in Phase B) may use function calls or postcard for performance, but applications never see these. See 00_index.md "Protocol Boundary Table" for the full matrix.

  7. Process owns state, display owns pixels. The process is the single source of truth for all application state — data models, widget IDs, panel hierarchy, and mutation logic. The display service has no application state: it draws what it receives and reports user interactions, but does not generate IDs, manage data models, diff content, or reconcile updates. (It does maintain rendering state — the current panel tree, widget input values, scroll positions, focus — but this is derived entirely from incoming messages, never invented.) The hub routes messages between them but owns no application state. There is no virtual DOM, no reconciliation engine, no framework-managed component lifecycle. Every mutation is an explicit message from the process. This is the Tk/ARexx model, not the browser model.

0.4 State Ownership

The three participants in the display protocol have distinct, non-overlapping responsibilities:

Concern Process Hub Display Service
ID allocation Assigns all panel, widget, and data item IDs
Application state Source of truth (data models, selection, mode)
State mutations Sends explicit commands (patch, insert, remove) Routes them Applies them to rendered output
Panel registry Tracks which panel IDs exist (for routing, wildcards, orphan detection) Tracks which panels are rendered (for hit testing, focus, scroll)
Rendering Draws widgets, handles input, emits ui.event
Event handling Receives ui.event, decides response, sends mutations Routes events to subscribed processes Detects user interaction, emits events

ID allocation rules:

  • Panel IDs: assigned by the process in the id header of ui.panel. The process chooses semantic, stable names (mail-sidebar, file-browser, compose).
  • Widget IDs: assigned by the process in the id property of code block declarations (~~~textinput id=to).
  • Data item IDs: assigned by the process (or its upstream data service) in the id field of JSON data objects. For example, maild generates email IDs; the process passes them through in ui.data.

No component of the system auto-generates IDs. If a process does not assign an id, the panel/widget/item cannot be targeted by subsequent commands.

Recovery model: If a process disconnects, its panels become orphaned (Section 10.3). When the process restarts, it sends fresh ui.panel messages — full state reconstruction from the process, not recovery from the display service. There is no "reconnect and diff against what's currently rendered."

0.5 Terminology

Term Definition
AMP AppMesh Protocol — markdown frontmatter wire format with BTreeMap headers + body
Panel A rectangular UI surface declared by a ui.panel message. May be a top-level window or a nested region within another panel.
Widget An interactive element within a panel, declared by a code block in the markdown body or created imperatively via ui.panel with a type header.
Display service A process that receives ui.* messages and renders them as visible surfaces (Wayland windows, HTML elements, terminal regions). It is a stateless renderer — it does not own application state.
Process Any program connected to the AMP hub that sends ui.* messages — a Mix script, a Rust daemon, a remote mesh peer. The process is the source of truth for application state.
Hub The AMP message router (cosmix-noded) that connects processes, display services, and mesh peers. Tracks panel IDs for routing but owns no application state.
Mix The ARexx-inspired scripting language with native AMP keywords (send, address, emit, on).
Mesh A WireGuard /24 network of cosmix nodes sharing a single trust domain.
RC Return code. 0 = success, 5 = warning, 10 = error, 20 = failure.

0.6 Notation

This specification uses RFC 2119 keywords: MUST, MUST NOT, SHOULD, SHOULD NOT, MAY.

Header names are shown in monospace. Message examples use the AMP wire format with --- delimiters.


1. Wire Format

1.1 Message Structure

Every AMP message consists of two parts: headers (a sorted key-value map) and a body (UTF-8 text). The wire format uses markdown frontmatter delimiters:

---
key1: value1
key2: value2
---
body content here

The opening ---\n begins headers. Each subsequent line until the closing ---\n is a header in key: value format. Everything after the closing delimiter is the body. The body is terminated by end-of-stream or the next message's opening ---\n.

1.2 Header Syntax

  • Headers are key: value pairs, one per line.
  • Keys are case-sensitive, lowercase, using underscores or hyphens (text_color, reply-to).
  • Values are UTF-8 strings. Leading whitespace after : is trimmed.
  • Header order is deterministic (lexicographic by key) for consistent serialization.
  • Duplicate keys: last value wins (but producers SHOULD NOT emit duplicates).

1.3 Body

  • UTF-8 encoded text.
  • Trailing whitespace is trimmed by parsers.
  • May be empty (command-only messages).
  • For ui.panel messages, the body is GFM markdown.
  • For ui.data messages, the body is JSON.
  • For ui.style and ui.theme messages, the body is key: value pairs.
  • Body termination and framing follow the Chapter 01 v0.5 wire format. Messages are terminated by the ---\nEOM\n end-of-message marker. Markdown horizontal rules (---) in the body are unambiguous because the parser requires both ---\n and EOM\n in sequence to terminate a message. Panel body content MUST NOT contain the literal sequence ---\nEOM\n (the AMP stream terminator). In practice, markdown bodies never do. See 01_amp-wire-protocol.md §5.1–5.2 for the grammar and stream framing rules.

1.4 Message Shapes

Shape Headers Body Use
Full Headers + fields Markdown/text/JSON Panels, events, rich responses
Command command + args (empty) Requests, simple responses
Data command: ui.data JSON Data-driven widget payloads
Empty (none) (none) Heartbeat, ACK, keepalive, stream separator

The empty message (---\n---\n---\nEOM\n) is the minimum valid AMP message. It carries no headers and no body. It serves as heartbeat on idle connections and as a natural separator in concatenated message streams. See 01_amp-wire-protocol.md §5.1 for the wire grammar.

1.5 Message Types

The type header identifies the message role:

Type Direction Semantics
request Process → service Expects a response
response Service → process Reply to a request (carries reply-to)
event Any → any Fire-and-forget notification
stream Service → process Ongoing data feed

Display protocol messages (ui.panel, ui.style, etc.) are typically event type — fire-and-forget from process to display service. ui.event messages from display to process are also event type. ui.subscribe is request type (expects acknowledgment).

1.6 Return Codes

RC Meaning Use
0 Success Normal completion
5 Warning Partial success, degraded
10 Error Bad arguments, not found
20 Failure Severe error, service degraded

1.7 Stream Framing

Stream framing, the parsing algorithm, transport-level message boundaries, and error recovery are defined in 01_amp-wire-protocol.md §5.1–5.2. This chapter does not restate those rules — Chapter 01 is authoritative for all wire-format concerns.

Key points for display protocol implementors:

  • Messages are terminated by the ---\nEOM\n two-line marker (Ch01 v0.5).
  • Markdown horizontal rules (---) in ui.panel bodies are unambiguous.
  • On WebSocket transports, one text frame = one complete AMP message.
  • On SMTP transports, the MIME boundary delimits the text/x-amp-panel part.

1.8 Required Headers

For non-empty messages, the following headers SHOULD be present:

Header Type Purpose
command string The command name (e.g., ui.panel)
msg_id string Message identity for request/response correlation (UUIDv7 recommended)
from string Source address

The command header is REQUIRED for all display protocol messages.

Note: msg_id is message identity (request/response correlation, deduplication, logging). id on ui.panel is panel identity (targeting, hierarchy, lifecycle). These are distinct concepts and MUST NOT be conflated. A single ui.panel message may have both: msg_id for transport and id for the panel it creates.


2. Addressing

2.1 AMP Address Format

Addresses use a DNS-style dot-separated hierarchy with a .amp suffix:

node.amp                    — node level (2 segments)
app.node.amp                — app on a node (3 segments)
port.app.node.amp           — specific endpoint (4 segments)
widget-id.app.node.amp      — widget within an app (4 segments)

Segment count is fixed at each level. Internal hierarchy within a segment uses hyphens: file-menu-save-as.edit.cachyos.amp.

Local shorthand: when addressing within the same node, the node and .amp suffix MAY be omitted. cosmix-mail is equivalent to cosmix-mail.local-node.amp.

2.2 Panel IDs

Panel IDs are strings that uniquely identify a panel within the hub's scope.

  • IDs MUST be unique across the hub at any given time.
  • IDs SHOULD be semantic and stable: mail-compose, file-browser, statusbar.
  • IDs MUST NOT contain whitespace or the * wildcard character.
  • Dot-separated prefixes MAY be used for logical grouping: mail.sidebar, mail.compose, mail.list.

2.3 Widget IDs

Widgets within a panel are identified by the id property in their code block declaration:

```textinput id=to placeholder="To..."
```

Widget IDs are scoped to their parent panel. The fully-qualified widget reference is widget-id.panel-id (for local) or widget-id.app.node.amp (for mesh).

Routing clarification: The mesh form (widget-id.app.node.amp) is a logical reference for documentation and Mix scripts, not a hub-routable endpoint. The hub routes messages to processes based on the application address (app.node.amp). Widget resolution within a panel is performed internally by the display service using the target or source header. Mix scripts address widgets via ui.event headers, not by sending directly to a four-segment widget address.

2.4 Wildcard Targeting

The target header in ui.style messages supports glob-style wildcards:

Pattern Matches
mail.* All panels with IDs starting with mail.
*.sidebar All panels with IDs ending with .sidebar
* All panels (use with extreme care)

Wildcards expand at the hub level. The display service receives individual targeted messages.

Implementation note: Wildcard expansion requires the hub to maintain a registry of all active panel IDs. This makes the hub stateful for display routing — it tracks panel creation and removal, not just connection routing. The hub already maintains process/port registries; the panel registry is a natural extension.


3. Display Protocol Commands

All display protocol commands use the ui. prefix. Commands are divided into two categories: display commands (process → display service) and interaction commands (display service → process or process → hub).

3.1 ui.panel — Create or Update Panel

Creates a new panel or updates an existing one. This is the primary display command.

Direction: Process → display service Semantics: If the id does not exist, create a new panel. If it exists, update it.

Required headers:

Header Type Description
command "ui.panel" Command identifier
id string Panel identifier

Optional headers (window):

Phase B migration: Window-management headers (position, layer, decorations, sticky, parent: desktop) are handled by cosmix-deskd in Phase A. In Phase B, these concerns migrate to a dedicated cosmix-shell command surface. Applications SHOULD treat these headers as layout hints, not contracts. A conformant renderer MAY ignore window-management headers in constrained environments (TUI, nested child-panel, federated display).

Header Type Default Description
parent string "desktop" Parent panel ID. desktop = top-level Wayland surface.
title string (none) Window title for CSD title bar
width size auto Panel width: px, %, rem, or auto
height size auto Panel height
position enum/coord auto left, right, center, top, bottom, top-right, top-left, bottom-right, bottom-left, or x,y coordinates
decorations list close,minimize,maximize,resize,move Comma-separated CSD decorations
layer enum normal background, normal, overlay, notification
sticky bool false Survives workspace switches
ttl integer (none) Auto-remove after N milliseconds from creation

Optional headers (layout):

Header Type Default Description
layout enum column column, row, grid, stack
grid_template string (none) Grid track definition for layout: grid. See Section 3.1.1.
gap float 0 Space between children (rem)
padding float/quad 0 Inner padding (rem). Single value = all sides. Four values = top right bottom left.
align enum stretch start, center, end, stretch
scrollable bool false Enable scroll container
overflow enum clip clip, scroll, visible

Optional headers (style):

Header Type Default Description
background color var(surface) Panel background. Hex or var(name).
text_color color var(text) Default text color
border_color color (none) Panel border color
border_width float 0 Border thickness (px)
border_radius float 0 Corner rounding (rem)
font_size float 1 Base font size (rem)
opacity float 1.0 Panel opacity, 0.0–1.0

Optional headers (behavior):

Header Type Default Description
script string (none) Path or URI to a Mix script file. See Section 7.
collect_values bool false Include all widget values in ui.event payloads. See Section 7.5.
subscribe string (none) Topic name for reactive data binding. See 03_amp-topic-pubsub.md.

Optional headers (federation):

Header Type Default Description
source_peer string (none) Originating mesh peer address. Set by transport, not by process.
permissions string (none) display for federated display-only panels

Body: GFM markdown content. See Section 4.

3.1.1 Grid Template Layout

When layout: grid is set, the grid_template header defines named regions with explicit sizes. This allows a single panel to express complex spatial layouts without spawning child panels for every region.

Syntax: Pipe-separated track definitions. Each track is name size:

grid_template: sidebar 250px | content 1fr | details 300px

Track sizes:

Unit Meaning
px Fixed pixel width/height
% Percentage of parent
fr Fractional unit (fills remaining space proportionally)
auto Fit to content
min Minimum content size
max Maximum content size

Rows: By default, grid_template defines columns. For row definitions, use grid_rows:

grid_template: sidebar 250px | content 1fr
grid_rows: header 3rem | body 1fr | footer 2rem

Child placement: Child panels or markdown sections are placed into named grid areas by setting grid_area: name on the child panel:

---
command: ui.panel
id: mail-app
layout: grid
grid_template: sidebar 250px | content 1fr
grid_rows: toolbar 3rem | body 1fr | status 2rem
---
---
command: ui.panel
id: mail-sidebar
parent: mail-app
grid_area: sidebar
---
# Mailboxes
- Inbox (3)
- Sent
---
command: ui.panel
id: mail-toolbar
parent: mail-app
grid_area: toolbar
---
[Compose](mail.compose) [Refresh](mail.refresh)

When grid_template is set without child panels using grid_area, the grid auto-places children in order (first child → first cell, etc.), matching CSS Grid auto-placement behavior. If a child specifies a grid_area name that does not exist in the parent's grid_template, that child falls into the auto-placement flow as if grid_area were not set.

When to use grid vs child panels:

Layout need Approach
Simple sidebar + content layout: row with two child panels
Header/body/footer layout: column with three child panels
Complex multi-region app layout: grid with grid_template — fewer panels, one layout message
Dynamic regions (add/remove panes) Child panels — each pane has independent lifecycle

Grid reduces panel count for static layouts. Child panels remain the right choice when regions have independent lifecycles (created/removed at different times, different processes owning different regions).

Update semantics: When ui.panel targets an existing ID: - The body is replaced entirely. - Headers present in the update message override the stored values. - Headers absent from the update message are preserved from the original. - To clear a header, set it to an empty string.

Example:

---
command: ui.panel
id: mail-compose
parent: desktop
layout: column
width: 40%
height: 60%
title: Compose
decorations: close,minimize
---
# New Message

~~~textinput id=to placeholder="To..."
~~~

~~~textinput id=subject placeholder="Subject"
~~~

---

~~~textarea id=body rows=15
~~~

---

[Send](mail.send) [Attach](mail.attach) [Discard](mail.discard)

3.2 ui.style — Restyle Panel or Widget

Changes style properties of an existing panel or widget without replacing content.

Direction: Process → display service

Required headers:

Header Type Description
command "ui.style" Command identifier
target string Panel ID, widget ID, or wildcard pattern

Body: key: value pairs, one per line. Keys are style property names from the property registry (Section 5). Values support var(name) references.

Example:

---
command: ui.style
target: file-browser
---
background: var(surface-dim)
border_color: var(primary)
border_width: 2

3.3 ui.remove — Remove Panel

Destroys a panel and frees its resources. All child panels are removed recursively (depth-first).

Direction: Process → display service

Required headers:

Header Type Description
command "ui.remove" Command identifier
target string Panel ID to remove

Body: Empty.

Cascade: When a panel is removed, all panels with parent equal to the removed panel's id are also removed, recursively. Events in flight for removed panels are silently dropped.

3.4 ui.event — User Interaction

Sent by the display service when the user interacts with a panel or widget. This is the primary feedback channel from display to process.

Direction: Display service → process

Required headers:

Header Type Description
command "ui.event" Command identifier
source string Panel ID where the event originated

Body: key: value pairs describing the event:

Key Type Description
action string Event type (e.g., click, select, input, submit, expand, collapse)
widget string Widget ID that generated the event (if widget-level)
value string Current value of the widget (for input widgets)
row integer Row index (for list/table selections)
item string Item ID (for list selections)
checked bool Checkbox/toggle state

Additional keys MAY be present depending on the widget type. See Section 6 for per-widget event payloads.

Example:

---
command: ui.event
source: file-browser
---
action: select
widget: file-list
row: 0
item: notes-md
value: notes.md

3.5 ui.theme — Set Theme Variables

Sets or switches the active theme. All var(name) references across all panels resolve against the current theme.

Direction: Process → display service

Optional headers:

Header Type Description
command "ui.theme" Command identifier
name string Theme name (for identification)

Body: key: value pairs mapping variable names to values. See Section 9 for standard variable names.

Example:

---
command: ui.theme
name: midnight
---
primary: #6366f1
primary-dim: #4f46e5
background: #0f0f1a
surface: #1a1a2e
surface-dim: #12122a
text: #e0e0e0
text-secondary: #999999
border: #333355
error: #ef4444
warning: #f59e0b
success: #10b981

3.6 ui.data — Push Data to Widget

Sends data to a data-driven widget (VirtualList, DataTable). See Section 12 for full data binding semantics.

Direction: Process → display service

Required headers:

Header Type Description
command "ui.data" Command identifier
target string Widget ID (within a panel)

Optional headers:

Header Type Default Description
action enum replace replace, insert, remove, update, patch, clear
index integer (none) Row index for insert
item string (none) Item ID for remove/update/patch

Body: JSON. Format depends on action:

Action Body format
replace JSON array of objects (full dataset)
insert Single JSON object
update Single JSON object (full replacement of item)
patch JSON-Patch array per RFC 6902, or partial JSON object (merge-patch per RFC 7396)
remove (empty)
clear (empty)

Every data object MUST contain an id field (string) for incremental operations.

Example (full replace):

---
command: ui.data
target: mailbox-list
---
[
  {"id": "inbox", "name": "Inbox", "unread": 3, "icon": "inbox"},
  {"id": "sent", "name": "Sent", "unread": 0, "icon": "send"},
  {"id": "trash", "name": "Trash", "unread": 0, "icon": "trash-2"}
]

Example (insert at position):

---
command: ui.data
target: mailbox-list
action: insert
index: 1
---
{"id": "drafts", "name": "Drafts", "unread": 1, "icon": "file-text"}

3.7 ui.template — Set Data Template

Defines a markdown template for rendering data items in a data-driven widget. The template is stamped once per data item with {field} interpolation. See Section 12.

Direction: Process → display service

Required headers:

Header Type Description
command "ui.template" Command identifier
target string Widget ID

Body: Markdown with {field} placeholders. Each placeholder is replaced with the corresponding field value from the data object.

Example:

---
command: ui.template
target: mailbox-list
---
- ![{icon}](lucide:{icon}) **{name}** ({unread})

3.8 ui.batch — Atomic Multi-Command

Executes multiple display commands atomically. The display service applies all commands before rendering a frame — no partial updates are visible to the user.

Direction: Process → display service

Required headers:

Header Type Description
command "ui.batch" Command identifier

Body: JSON array of command objects. Each object has command (string), headers (object), and optional body (string).

Design note: This is the only display command with a JSON body (all others use markdown or key-value pairs). JSON is chosen here because batch commands must be parsed atomically — concatenated AMP messages would require the renderer to buffer and group them, defeating the atomicity guarantee. The trade-off against the "cat and grep" principle is accepted for this single command.

Example:

---
command: ui.batch
---
[
  {
    "command": "ui.style",
    "headers": {"target": "sidebar"},
    "body": "width: 250\nbackground: var(surface-dim)"
  },
  {
    "command": "ui.style",
    "headers": {"target": "content"},
    "body": "width: 100%"
  }
]

3.9 ui.subscribe — Register Event Filter

Registers a process to receive ui.event messages matching a filter. This is the hub-level primitive that enables Mix's on keyword (Section 7).

Direction: Process → hub

Required headers:

Header Type Description
command "ui.subscribe" Command identifier
type "request" Expects acknowledgment

Body: key: value filter criteria:

Key Type Description
source string Panel ID to watch (exact match or glob)
action string Event action to filter (optional — omit for all actions)

Response: RC 0 with subscription ID.

Semantics: After subscription, the hub routes matching ui.event messages to the subscribing process. Multiple subscriptions from the same process are allowed.

Example:

---
command: ui.subscribe
type: request
msg_id: sub-001
---
source: file-browser
action: select

3.10 ui.unsubscribe — Deregister Event Filter

Removes a previously registered event subscription.

Direction: Process → hub

Required headers:

Header Type Description
command "ui.unsubscribe" Command identifier
target string Subscription ID (from ui.subscribe response) or panel source to unsubscribe from

Body: Empty.

3.11 Widget and Menu Introspection

These commands enable ARexx-style discovery and scripting of the display surface. A script can enumerate widgets, read their state, and invoke them without hardcoding IDs — the same discover-then-script pattern used on the Amiga. These are handled by the display service, not individual apps.

Widget introspection

Command Args Returns Notes
ui.list {"prefix": "..."} [{id, kind, label, state...}] All registered widgets (optional prefix filter)
ui.get {"id": "..."} or {"ids": [...]} [{id, kind, state...}] Read specific widget state
ui.invoke {"id": "..."} {"status":"ok"} Click/toggle a widget
ui.highlight {"id": "...", "ms": N} {"status":"ok"} Visual pulse on a widget
ui.set {"id": "...", "value": "..."} {"status":"ok"} Set widget value

Menu introspection

Command Args Returns Notes
menu.list {} [{id, label, shortcut, enabled, menu}] All menu items
menu.invoke {"id": "..."} {"status":"ok"} Simulate menu click
menu.highlight {"id": "...", "ms": N} {"status":"ok"} Visual pulse on menu item
menu.close {} {"status":"ok"} Close open dropdown

Lifecycle

Command Args Returns Notes
config.changed {} {"status":"ok"} Theme reload notification (auto-handled)

Discovery flow for scripts

A script targeting an unknown panel should:

  1. hub.list → get registered services
  2. menu.list → discover menu items and their IDs
  3. ui.list → discover interactive widgets and their current state
  4. ui.get → read specific widget values before acting
  5. Then invoke/set as needed

This is the ARexx pattern: discover first, script second. Never hardcode widget IDs without verifying they exist via ui.list.


4. Layer 1 — Markdown Content

4.1 Parser

The markdown body of a ui.panel message is parsed as GitHub Flavored Markdown (GFM) using the following extensions:

  • Strikethrough (~~text~~)
  • Task lists (- [x] item)
  • Tables (| col | col |)

Parsers MUST use these extensions. Parsers MUST NOT add custom syntax beyond the code-block-as-widget convention defined below.

4.2 Element Mapping

Every GFM element maps to a visual widget in the rendered panel:

Markdown Element Widget Role Renderer Behavior
# Heading (1-6) Section title Container boundary. Level determines visual hierarchy.
Paragraph Text block Inline-formatted text with wrapping.
- item Unordered list item Bullet + indented content. Clickable — emits ui.event with action: select.
1. item Ordered list item Number + indented content.
- [x] item Checkbox item Checkbox + label. Toggle emits ui.event with action: toggle, checked: true/false.
**bold** Strong emphasis Increased font weight.
*italic* Light emphasis Italic style.
~~strike~~ Strikethrough Line-through decoration.
`code` Inline code Monospace font + background.
[text](uri) Action button Clickable. Click dispatches action URI (Section 8).
![alt](src) Icon or image If src starts with lucide:, render as Lucide icon. Otherwise load as image.
> blockquote Info/status panel Inset container with accent left border.
--- Divider Horizontal rule (1px).
Table Data grid Column headers + rows. Rendered as grid layout.
Fenced code block Widget or code display If language hint is a known widget type → interactive widget (Section 4.3). Otherwise → monospace code block.

4.3 Code Blocks as Widget Declarations

Fenced code blocks (backtick ``` or tilde ~~~) with a language hint declare interactive widgets when the hint matches a known widget type (Section 6):

```textinput id=to placeholder="To..." value="alice@example.com"
```

Parsing rules:

  1. Extract the first word of the language hint as the widget type name.
  2. Look up the name in the widget type registry (Section 6). If not found, render as a plain code block.
  3. Parse remaining words as key=value properties. Quoted values (key="multi word") preserve spaces.
  4. The code block content (lines between the fences) is the widget's initial value/content.

Example with content:

```textarea id=body rows=10
Default text content goes here.
It can span multiple lines.
```

4.4 ~~~mix Code Blocks — Behavior Attachment

A fenced code block with language hint mix declares an inline script:

~~~mix
on ui.event from "my-panel" action "submit"
  $name = $event.name
  send "greeting-service" greet name=$name
end
~~~

Rules:

  1. ~~~mix blocks MUST NOT be rendered as visible content.
  2. The display service MUST extract ~~~mix blocks and forward them to the script execution service (Section 7).
  3. Multiple ~~~mix blocks in a single panel body are concatenated in order.
  4. ~~~mix blocks in federated panels (source_peer is set) MUST be stripped silently.

Markdown links are action buttons. The URL component is an action URI (Section 8):

[Send](mail.send)
[Reply](mail.reply:msg-id-123)
[Delete](mail.delete:msg-id-123?confirm=true)
[Open Settings](ui.panel:settings)
[Visit Site](xdg-open:https://example.com)

When the user clicks a link, the display service dispatches the action URI. For AMP commands, this emits an AMP message. For xdg-open, this opens the URL in the system browser. See Section 8 for the complete scheme.

4.6 Images as Icons

Image syntax references icons or loads images:

![inbox](lucide:inbox)              — Lucide icon by name
![avatar](https://example.com/a.jpg) — Remote image
![screenshot](/tmp/capture.png)      — Local file

The lucide: prefix resolves to the Lucide icon set. Renderers SHOULD support at minimum the Lucide icon set. Unknown icon names render as a placeholder.


5. Layer 2 — AMP Header Properties

5.1 Size Units

Size values in headers (width, height, gap, padding, etc.) accept these units:

Unit Syntax Meaning
Pixels 400 or 400px Absolute device pixels
Percent 40% Relative to parent dimension
Rem 2rem Relative to base font size (16px default)
Auto auto Fit to content

Bare numbers without a unit suffix are interpreted as pixels for width/height and rem for gap/padding/border_radius/font_size.

5.2 Color Values

Color values in headers and style bodies accept:

Format Example Description
Hex #6366f1 6-digit hex RGB
Hex+alpha #6366f180 8-digit hex RGBA
Short hex #63f 3-digit shorthand
Variable var(primary) Theme variable reference (Section 9)

Named colors (e.g., red, blue) are NOT supported. Use hex values.

5.3 Variable References

The var(name) syntax resolves a value from the current theme (Section 9):

background: var(surface)
text_color: var(text-secondary)
border_color: var(primary)

Resolution is flat — one lookup, no cascade, no fallback chain. If the variable is not defined in the current theme, the renderer SHOULD use a sensible default (black for text, white for background, transparent for borders).

5.4 Property Categories

All properties that appear in ui.panel headers or ui.style bodies are defined in Section 3.1. This section serves as a cross-reference index.

Window properties: id, parent, title, width, height, position, decorations, layer, sticky, ttl Layout properties: layout, gap, padding, align, scrollable, overflow Style properties: background, text_color, border_color, border_width, border_radius, font_size, opacity Behavior properties: script Federation properties: source_peer, permissions

5.5 The script Header

The script header on a ui.panel message attaches a Mix script to the panel:

---
command: ui.panel
id: file-browser
script: ~/.config/cosmix/scripts/file-browser.mx
---
# Files
...

The value is a file path or URI. The script is loaded and executed when the panel is created, and terminated when the panel is removed. See Section 7 for full lifecycle semantics.


6. Widget Type Registry

This section defines the 22 widget types recognized by the display protocol (plus the tooltip cross-cutting property). For each widget type: accepted properties, emitted events, code block syntax, and degradation behavior.

6.1 Display Widgets

6.1.1 Label

A text display element.

Aliases: label Code block: ~~~label id=status with text content. Degradation: Rendered as plain text.

Property Type Default Description
id string (required) Widget identifier
text_color color var(text) Text color
font_size float 1 Font size (rem)
font_weight integer 400 Font weight (100–900)
align enum start Text alignment: start, center, end
wrap bool true Enable text wrapping

Events: None. Labels are non-interactive.

6.1.2 Icon

An icon from the Lucide icon set or a custom SVG.

Aliases: icon Code block: ~~~icon id=status name=check-circle Degradation: Rendered as [icon-name] text.

Property Type Default Description
id string (required) Widget identifier
name string (required) Lucide icon name
size float 1.25 Icon size (rem)
color color var(text) Icon color

Events: None.

6.1.3 Image

Displays a raster image (PNG, JPEG, WebP) or SVG.

Aliases: image, img Code block: ~~~image id=avatar src=/path/to/image.png Degradation: Rendered as [alt-text].

Property Type Default Description
id string (required) Widget identifier
src string (required) Image path, URL, or blob reference
alt string "" Alt text for accessibility
width size auto Image width
height size auto Image height
fit enum contain contain, cover, fill, none

Events: None.

Security: For federated panels (source_peer set), src values MUST be validated. Local file paths (/path/to/..., ~/..., file://) MUST be rejected — federated panels may only reference blob IDs or HTTPS URLs. Renderers MUST NOT resolve relative paths against the local filesystem for federated content.

6.1.4 Markdown

A sub-document rendered as markdown. Enables nested markdown content within imperatively-created widget trees.

Aliases: markdown, md Code block: ~~~markdown with markdown content. Degradation: Rendered as plain text (markdown is already readable).

Property Type Default Description
id string (required) Widget identifier

Events: Action link clicks within the markdown are dispatched as ui.event with the action URI. Same behavior as panel-level markdown.

6.2 Layout Widgets

6.2.1 Container

A layout container that holds other widgets. Uses flexbox semantics via taffy.

Aliases: container, div Code block: ~~~container id=toolbar layout=row gap=0.5 Degradation: Contents rendered sequentially.

Property Type Default Description
id string (required) Widget identifier
layout enum column column, row, grid, stack
gap float 0 Space between children (rem)
padding float/quad 0 Inner padding (rem)
align enum stretch start, center, end, stretch
background color transparent Container background
border_color color (none) Border color
border_width float 0 Border thickness (px)
border_radius float 0 Corner rounding (rem)

Events: None. Containers are structural.

6.2.2 ScrollContainer

A container with scrollable overflow.

Aliases: scroll, scrollcontainer Code block: ~~~scroll id=content direction=vertical Degradation: Contents rendered sequentially.

Property Type Default Description
id string (required) Widget identifier
direction enum vertical vertical, horizontal, both
max_height size (none) Maximum height before scrolling

Events:

Event Payload Trigger
scroll offset: float, max: float User scrolls

6.2.3 Tabs

A tabbed container with tab bar and content switching.

Aliases: tabs Code block: ~~~tabs id=mail-tabs active=inbox tabs="Inbox,Sent,Drafts" Degradation: All tab contents rendered sequentially with tab names as headings.

Property Type Default Description
id string (required) Widget identifier
tabs string (required) Comma-separated tab labels
active string (first tab) ID or label of the active tab
position enum top Tab bar position: top, bottom, left, right
closable bool false Show close button on tabs

Events:

Event Payload Trigger
select value: string (tab label) User clicks a tab
close value: string (tab label) User clicks tab close button

6.2.4 SplitPane

A container with two children separated by a draggable divider.

Aliases: splitpane, split Code block: ~~~split id=main direction=horizontal ratio=30:70 Degradation: Both panes rendered sequentially.

Property Type Default Description
id string (required) Widget identifier
direction enum horizontal horizontal (left/right) or vertical (top/bottom)
ratio string 50:50 Initial split ratio (e.g., 30:70)
min_size float 5 Minimum pane size (%)
collapsible bool false Allow collapsing to zero

Events:

Event Payload Trigger
resize ratio: string (new ratio) User drags divider

6.3 Input Widgets

6.3.1 Button

A clickable button.

Aliases: button, btn Code block: ~~~button id=send label="Send Message" variant=primary Degradation: Rendered as [label] text.

Property Type Default Description
id string (required) Widget identifier
label string "" Button text
variant enum default default, primary, danger, ghost
disabled bool false Disable interaction
icon string (none) Lucide icon name

Events:

Event Payload Trigger
click (none) User clicks button

6.3.2 TextInput

A single-line text input field.

Aliases: textinput, input Code block: ~~~textinput id=email placeholder="Email" value="user@example.com" Degradation: Rendered as [value] or [placeholder] text.

Property Type Default Description
id string (required) Widget identifier
value string "" Current text value
placeholder string "" Placeholder text
disabled bool false Disable interaction
password bool false Mask input characters
max_length integer (none) Maximum character count

Events:

Event Payload Trigger
input value: string Text changes (debounced)
submit value: string User presses Enter
focus (none) Widget gains focus
blur value: string Widget loses focus

6.3.3 TextArea

A multi-line text input. Implementation complexity: very hard (cosmic-text integration for cursor, selection, wrapping, IME). Renderers handle local text buffering, cursor, selection, and IME state; ui.event with action: input SHOULD be debounced or emitted on blur/submit to avoid per-keystroke protocol overhead.

Aliases: textarea Code block: ~~~textarea id=body rows=10 with initial content. Degradation: Rendered as indented text block.

Property Type Default Description
id string (required) Widget identifier
value string (content) Current text. Initial value is the code block content.
placeholder string "" Placeholder text
rows integer 5 Visible row count
disabled bool false Disable interaction
max_length integer (none) Maximum character count

Events:

Event Payload Trigger
input value: string Text changes (debounced)
focus (none) Widget gains focus
blur value: string Widget loses focus

6.3.4 Dropdown

A selection dropdown (single-select).

Aliases: dropdown, select Code block: ~~~dropdown id=priority options="Low,Normal,High" value="Normal" Degradation: Rendered as [value] text.

Property Type Default Description
id string (required) Widget identifier
options string (required) Comma-separated option labels. Or JSON array for label+value: [{"label":"Low","value":"1"}]
value string (first option) Currently selected value
placeholder string "" Placeholder when no selection
disabled bool false Disable interaction

Events:

Event Payload Trigger
change value: string Selection changes

6.3.5 Checkbox

A boolean checkbox with label.

Aliases: checkbox Code block: ~~~checkbox id=agree label="I agree" checked=true Degradation: Rendered as [x] or [ ] with label.

Property Type Default Description
id string (required) Widget identifier
label string "" Checkbox label text
checked bool false Current checked state
disabled bool false Disable interaction

Events:

Event Payload Trigger
change checked: bool User toggles checkbox

6.3.6 Toggle

A slide-switch toggle (on/off).

Aliases: toggle, switch Code block: ~~~toggle id=dark_mode label="Dark mode" checked=true Degradation: Rendered as (on) or (off) with label.

Property Type Default Description
id string (required) Widget identifier
label string "" Toggle label text
checked bool false Current state
disabled bool false Disable interaction

Events:

Event Payload Trigger
change checked: bool User toggles switch

6.3.7 RadioGroup

A mutually exclusive set of options (only one can be selected).

Aliases: radiogroup, radio Code block: ~~~radio id=priority options="Low,Normal,High" value="Normal" Degradation: Rendered as (x) selected / ( ) unselected text list.

Property Type Default Description
id string (required) Widget identifier
options string (required) Comma-separated option labels. Or JSON: [{"label":"Low","value":"1"}]
value string (first option) Currently selected value
direction enum column column or row layout
disabled bool false Disable interaction

Events:

Event Payload Trigger
change value: string Selection changes

6.3.8 Slider

A range slider for numeric values.

Aliases: slider, range Code block: ~~~slider id=opacity min=0 max=100 value=80 step=1 Degradation: Rendered as [value] text.

Property Type Default Description
id string (required) Widget identifier
min float 0 Minimum value
max float 100 Maximum value
value float min Current value
step float 1 Step increment
label string (none) Optional label
disabled bool false Disable interaction

Events:

Event Payload Trigger
input value: float User drags slider (continuous, may be coalesced)
change value: float User releases slider (final value)

6.3.9 Progress

A progress bar or indeterminate spinner.

Aliases: progress Code block: ~~~progress id=upload value=65 max=100 label="Uploading..." Degradation: Rendered as [65%] or [loading...] text.

Property Type Default Description
id string (required) Widget identifier
value float (none) Current progress. Omit for indeterminate (spinner).
max float 100 Maximum value
label string (none) Text label beside the bar
variant enum default default, success, warning, error

Events: None. Progress bars are display-only.

Updates: Use ui.set to update value (widget state): send "ui" set target="upload" value="75". Use ui.style to update variant and label (presentation). The distinction: value is data the widget tracks, variant/label are how it looks.

6.4 Data Widgets

6.4.1 VirtualList

A scrollable list that only renders visible items (windowed/virtualized rendering). For large datasets. Uses ui.template for item rendering and ui.data for data.

Aliases: virtuallist, vlist Code block: ~~~vlist id=email-list item_height=2.5 Degradation: First N items rendered as list items (N = renderer discretion).

Property Type Default Description
id string (required) Widget identifier
item_height float 2.5 Height of each item (rem)
indent bool false Enable indentation (for tree-like lists)
selectable bool true Items are selectable
multi_select bool false Allow multi-selection
background color transparent List background

Events:

Event Payload Trigger
select item: string, index: int User selects an item
activate item: string, index: int User double-clicks/enters an item
expand item: string User expands a tree node (when indent: true)
collapse item: string User collapses a tree node
scroll offset: int, visible: int Scroll position changes

Data format: JSON array of objects. Each object MUST have an id field. For indented/tree lists, objects MAY have a depth field (integer, 0-based) and expandable field (bool).

[
  {"id": "inbox", "name": "Inbox", "unread": 3, "icon": "inbox"},
  {"id": "sent", "name": "Sent", "unread": 0, "icon": "send"}
]

Template: Markdown with {field} interpolation. Rendered once per item.

- ![{icon}](lucide:{icon}) **{name}** ({unread})

6.4.2 DataTable

A tabular data display with column headers, sorting, and virtual scrolling.

Aliases: datatable, table Code block: ~~~datatable id=files columns="Name,Size,Modified" sortable=true Degradation: Rendered as a GFM markdown table (first N rows).

Property Type Default Description
id string (required) Widget identifier
columns string (required) Comma-separated column names. Or JSON: [{"name":"Name","key":"name","width":"40%"}]
sortable bool false Enable column header click to sort
sort_column string (none) Currently sorted column key
sort_order enum asc asc or desc
selectable bool true Rows are selectable
row_height float 2 Row height (rem)

Events:

Event Payload Trigger
select item: string, row: int User selects a row
activate item: string, row: int User double-clicks a row
sort column: string, order: string User clicks column header

Data format: JSON array of objects. Keys correspond to column keys.

[
  {"id": "notes-md", "name": "notes.md", "size": "2.1 KB", "modified": "2026-04-06"},
  {"id": "report-pdf", "name": "report.pdf", "size": "145 KB", "modified": "2026-04-05"}
]

6.5 Chrome Widgets

6.5.1 MenuBar

Application menu bar with CSD (client-side decoration) integration.

Aliases: menubar Code block: Not typically used in markdown. Created imperatively or by app frameworks. Degradation: Menu items rendered as a list.

Property Type Default Description
id string (required) Widget identifier
menus JSON (required) Menu structure: [{"label":"File","items":[{"id":"file.new","label":"New","shortcut":"Ctrl+N"}]}]
caption_buttons bool true Show minimize/maximize/close buttons

Events:

Event Payload Trigger
invoke item: string (menu item ID) User clicks menu item

6.5.2 ContextMenu

Right-click popup menu.

Aliases: contextmenu Code block: Not used in markdown. Triggered via ui.event with action: context-menu. Degradation: Not rendered (context menus are inherently interactive).

Property Type Default Description
id string (required) Widget identifier
items JSON (required) Menu items: [{"id":"edit.copy","label":"Copy","shortcut":"Ctrl+C"}]
position string (required) x,y coordinates

Events:

Event Payload Trigger
invoke item: string User clicks menu item
close (none) Menu dismissed

6.5.3 Dialog

A modal overlay anchored to its parent panel.

Aliases: dialog, modal Code block: ~~~dialog id=confirm title="Confirm Delete" with markdown body. Degradation: Content rendered as blockquote.

Property Type Default Description
id string (required) Widget identifier
title string (none) Dialog title
width size 400 Dialog width
closable bool true Show close button
backdrop bool true Show dimmed backdrop behind dialog

Events:

Event Payload Trigger
close (none) User clicks close or backdrop

Dialog content is markdown (headings, text, buttons via action links). The dialog is modal to its parent panel — other panels remain interactive.

Nesting: Dialogs MAY spawn child dialogs (e.g., a confirmation inside a settings dialog). Each child dialog is modal to its parent dialog. When a parent dialog is closed, all child dialogs are closed first (depth-first cascade, same as panel removal in Section 3.3).

Parent removal: If a dialog's parent panel is removed, the dialog is removed as part of the cascade. Events in flight for the dialog are dropped.

6.5.4 Tooltip

A hover-triggered popup with informational content.

Aliases: tooltip Code block: Not a standalone widget. Applied as a property on other widgets via tooltip="Help text". Degradation: Not rendered (content is supplementary).

Any interactive widget MAY include a tooltip property:

~~~button id=send label="Send" tooltip="Send the message (Ctrl+Enter)"
~~~
Property Type Default Description
tooltip string (none) Tooltip text (available on all interactive widgets)
tooltip_position enum auto auto, top, bottom, left, right

The renderer displays the tooltip on hover (mouse) or long-focus (keyboard/touch). Tooltips are plain text, not markdown.

Events: None.

6.6 Focus and Keyboard Navigation

Renderers at Level 1 or above MUST implement focus management for interactive widgets.

Focus order: Widgets receive focus in document order (the order they appear in the markdown body or the order they were created via ui.panel). The tabindex property overrides document order:

Property Type Default Description
tabindex integer 0 Focus order. 0 = document order. Positive = explicit order. -1 = skip in tab sequence (focusable only programmatically).

Keyboard requirements:

Key Behavior
Tab Move focus to next focusable widget
Shift+Tab Move focus to previous focusable widget
Enter Activate focused button/link, submit focused TextInput
Space Toggle focused Checkbox/Toggle, activate focused Button
Escape Close focused Dialog/ContextMenu/Dropdown
Arrow keys Navigate within Dropdown options, VirtualList/DataTable rows, Slider value

Focus events: When a widget gains focus, the renderer MAY emit a ui.event with action: focus. When it loses focus, action: blur with the current value.

Keyboard shortcuts: MenuBar items with a shortcut property (e.g., "Ctrl+N") are global within the panel's window. The renderer MUST intercept these key combinations and emit the corresponding invoke event.

6.7 Accessibility

Note: Full accessibility semantics are deferred to v1.1. This section defines the minimum requirements for v1.0.

Renderers SHOULD map widget types to platform accessibility roles:

Widget Accessibility Role
Button button
TextInput textbox
TextArea textbox (multiline)
Checkbox checkbox
Toggle switch
Slider slider
Dropdown combobox
VirtualList list / listitem
DataTable table / row / cell
Dialog dialog
MenuBar menubar / menuitem
Heading heading (with level)

Renderers using accesskit SHOULD build an accessibility tree from the widget tree. Image widgets MUST expose their alt text to screen readers.


7. Layer 3 — Mix Script Behavior

7.1 Overview

Mix scripts provide the behavior layer — the logic that responds to user interactions, manages data, and updates panels dynamically. Mix is an ARexx-inspired scripting language with native AMP keywords (send, address, emit) already implemented, and a new on keyword defined by this specification.

The display service NEVER executes scripts. Scripts run as processes connected to the hub. The hub routes ui.* commands from script → display and ui.event from display → script. This separation keeps the renderer simple and secure.

7.2 Attachment Modes

7.2.1 Inline Scripts

A ~~~mix code block in a ui.panel markdown body attaches behavior to that panel:

---
command: ui.panel
id: notepad
title: Notes
width: 400
height: 500
---
# Quick Notes

~~~textarea id=editor rows=20
~~~

[Save](note.save) [Clear](note.clear)

~~~mix
on ui.event from "notepad" action "note.save"
  $content = $event.editor
  send "file-service" write path="~/notes.md" content=$content
  emit "ui" panel id="toast" layer="notification" ttl="2000" body="> Saved."
end
~~~

Processing:

  1. Display service receives ui.panel message.
  2. Display service parses the markdown body, encounters ~~~mix block.
  3. Display service extracts the script text and forwards it to a script execution service. The handoff mechanism is implementation-specific — it could be an internal ui.script message on the hub, a direct function call within the same process, or a Unix socket to a dedicated script runner. The protocol does not mandate how scripts are delivered to the executor, only that the display service does not execute them itself.
  4. The script service creates a Mix evaluator, injects $PANEL_ID as a context variable, and executes the script.
  5. The script's on handlers register event subscriptions with the hub.
  6. The remaining markdown (without the ~~~mix block) is rendered normally.

7.2.2 Referenced Scripts

The script header on a ui.panel message names an external Mix script:

---
command: ui.panel
id: file-browser
script: ~/.config/cosmix/scripts/file-browser.mx
title: Files
---
# ~/Documents

Processing:

  1. Display service receives ui.panel, notes script header.
  2. Display service forwards the script path to the script execution service.
  3. Script service loads the file, creates a Mix evaluator, injects $PANEL_ID, and executes.
  4. The script creates sub-widgets, loads data, registers event handlers.

7.2.3 Standalone Scripts

A Mix script launched independently (via mix file-browser.mx or by a daemon) connects to the hub and sends ui.* messages directly. No ui.panel message is needed first — the script IS the process:

#!/usr/bin/env mix
-- File browser — standalone script app
address "ui"

send "panel" id="file-browser" parent="desktop" title="Files" +
     width="800" height="600" layout="column"

send "panel" id="file-sidebar" parent="file-browser" width="250" +
     body markdown("
# Folders

- ![home](lucide:home) Home
- ![documents](lucide:folder) Documents
- ![downloads](lucide:download) Downloads
")

on ui.event from "file-sidebar" action "select"
  $path = $event.value
  $files = send "file-service" list path=$path
  send "panel" id="file-list" parent="file-browser" body format_file_list($files)
end

Lifecycle: Standalone scripts manage their own lifecycle. They SHOULD remove their panels on exit. The hub MAY auto-remove orphaned panels when a process disconnects.

7.3 The on Keyword

The on keyword registers an asynchronous event handler. It is a new Mix language keyword defined by this specification (not yet implemented in mix-core — implementation follows the spec).

Syntax:

on command.name [from "source"] [action "type"]
  -- handler body
end

The command name is a bare dotted identifier (unquoted), matching the Mix parser's parse_on rule (04_mix-language-reference.md). Filter values (from, action) are quoted strings.

Semantics:

  1. on sends a ui.subscribe message to the hub with the specified filter criteria.
  2. The hub routes matching ui.event messages to the script's process.
  3. When a matching event arrives, the Mix evaluator executes the handler body.
  4. The $event variable is implicitly available inside the handler, containing all event payload fields as a Mix object.
  5. Multiple on handlers for the same source are allowed. All matching handlers execute in registration order.
  6. Concurrency model: cooperative, single-threaded, queued. Handlers execute one at a time in a single event loop, consistent with ARexx's single-threaded message loop. If events arrive while a handler is executing, they are queued and dispatched in order after the current handler completes. Handlers MUST NOT be preempted or run concurrently. A handler that blocks (e.g., waiting on a send response) holds the event loop — this is intentional and prevents reentrancy bugs. Long-running handlers SHOULD use emit (fire-and-forget) instead of send (blocking) where possible.

Filter parameters:

Parameter Type Description
command bare identifier Dotted AMP command to match (typically ui.event)
from "source" string Panel or widget ID to filter on (exact match or glob)
action "type" string Event action type to filter on

Examples:

-- Handle any event from a panel
on ui.event from "file-browser"
  say "Event:" .. $event.action .. " from " .. $event.widget
end

-- Handle only select events
on ui.event from "mailbox-list" action "select"
  $mailbox = $event.item
  $emails = send "maild" list mailbox=$mailbox
  send "panel" id="email-list" body format_emails($emails)
end

-- Handle events from any panel matching a glob
on ui.event from "settings.*" action "change"
  send "config-service" set key=$event.widget value=$event.value
end

7.4 The off Keyword

Deregisters an event handler previously registered with on.

Syntax:

off command.name [from "source"] [action "type"]

Under the hood, sends ui.unsubscribe to the hub. The filter parameters must match those used in the corresponding on call.

7.5 The $event Variable

Inside an on handler body, the $event variable is a Mix object containing all key-value pairs from the ui.event body:

on ui.event from "file-list" action "select"
  say $event.action     -- "select"
  say $event.widget     -- "file-list"
  say $event.item       -- "notes-md"
  say $event.row        -- 0
  say $event.value      -- "notes.md"
end

By default, $event contains only the fields emitted by the triggering widget. For form-style panels where a script needs all widget values at once, the panel MAY declare collect_values: true:

---
command: ui.panel
id: compose
collect_values: true
---

When collect_values is true, the display service includes the current value of every named input widget in the panel as additional fields on every ui.event from that panel. This allows form submission to access all field values:

on ui.event from "compose" action "mail.send"
  $to = $event.to           -- value of textinput id=to
  $subject = $event.subject -- value of textinput id=subject
  $body = $event.body       -- value of textarea id=body
  send "maild" send to=$to subject=$subject body=$body
end

Without collect_values, the script would need to query individual widget values via ui.get or track state manually. The opt-in design avoids serializing 30 widget values on every keystroke in large panels.

7.6 Script Lifecycle

Mode Created Destroyed Cleanup
Inline (~~~mix) When panel is created When panel is removed Subscriptions auto-deregistered
Referenced (script:) When panel is created When panel is removed Subscriptions auto-deregistered
Standalone When script starts When script exits Hub MAY auto-remove orphaned panels

When a script's process disconnects from the hub, all its event subscriptions are automatically deregistered. For inline and referenced scripts, panel removal triggers script termination, which triggers subscription cleanup — the cascade is automatic.

7.7 Script Security

Local and mesh scripts: Full AMP access. Scripts can create panels, send commands to any service, read files (via file service), and register for any event.

Federated panels: Scripts are FORBIDDEN. The display service MUST strip ~~~mix code blocks and ignore the script header for any panel with source_peer set. This prevents remote code execution.

7.8 Reusable Components

Mix functions serve as reusable UI components:

-- lib/components.mx — shared UI patterns

function file_tree($parent_id, $root_path, $widget_id)
  address "ui"

  send "panel" id=$widget_id parent=$parent_id scrollable="true"
  send "template" target=$widget_id body="- ![{icon}](lucide:{icon}) **{name}**"

  $entries = send "file-service" list path=$root_path
  send "data" target=$widget_id body=$entries

  on ui.event from $widget_id action "expand"
    $children = send "file-service" list path=$event.path
    send "data" target=$widget_id action="insert" index=$event.index body=$children
  end

  on ui.event from $widget_id action "collapse"
    send "data" target=$widget_id action="remove" item=$event.item
  end

  return $widget_id
end

function searchable_list($parent_id, $widget_id, $placeholder)
  address "ui"

  $cid = $widget_id .. "-container"
  $sid = $widget_id .. "-search"
  send "panel" id=$cid parent=$parent_id layout="column"
  send "panel" id=$sid parent=$cid +
       body="~~~textinput id=search placeholder=\"" .. $placeholder .. "\"\n~~~"
  send "panel" id=$widget_id parent=$cid

  on ui.event from $sid action "input"
    send "data" target=$widget_id filter=$event.value
  end

  return $widget_id
end

Usage:

#!/usr/bin/env mix
source "lib/components.mx"
address "ui"

send "panel" id="browser" parent="desktop" title="Files" layout="row" +
     width="800" height="600"

-- Compose from reusable components
$tree = file_tree("browser", expand("~/"), "nav")
$list = searchable_list("browser", "files", "Search files...")

on ui.event from $tree action "select"
  if not is_dir($event.path) then
    $content = send "file-service" read path=$event.path
    send "panel" id="preview" parent="browser" body=$content
  end
end

8. Action URI Scheme

Action URIs appear in markdown links: [Label](uri). When the user clicks the link, the display service dispatches the URI.

8.1 Schemes

Scheme Syntax Behavior
AMP command command.verb Send AMP request to the source process
AMP command with param command.verb:param AMP request with positional parameter
AMP command with named params command.verb:param?key=val AMP request with named parameters
Panel navigation ui.panel:panel-id Focus or create the named panel
Launch launch:target Spawn a process (local/mesh only)
External URL xdg-open:https://... Open in system browser
Direct URL https://... or http://... Open in system browser (shorthand)
Federated reply amp-reply:command Compose reply email with AMP command
Federated request amp-request:command WebSocket request if live, else amp-reply
Email mailto:address Compose plain email

8.2 AMP Command Dispatch

When a link with an AMP command URI is clicked:

  1. The display service MUST emit a ui.event with action set to the full URI string.
  2. The hub routes the event to subscribed processes.
  3. The subscribing process handles the command.

If no process is subscribed for the panel's events, the event is logged and dropped. The display service MUST NOT interpret or dispatch AMP commands directly — all command logic flows through processes. This ensures deterministic behavior regardless of runtime subscription state.

8.3 Security Restrictions

Scheme Local Mesh Federated
AMP command allowed allowed via amp-reply only
launch: allowed allowed BLOCKED
xdg-open: allowed allowed confirmation required
amp-reply: N/A N/A allowed
amp-request: N/A N/A allowed (if WebSocket live)

9. Theme System

9.1 Theme Variables

A theme is a flat map of variable names to values. There is no cascade, no inheritance, no scoping. One theme is active at a time. All var(name) references across all panels resolve against the active theme.

9.2 Standard Variables

Renderers SHOULD support these standard variable names:

Variable Semantic Typical Light Typical Dark
primary Accent color #6366f1 #818cf8
primary-dim Dimmed accent #4f46e5 #6366f1
background Root background #ffffff #0f0f1a
surface Panel/card background #f8f8f8 #1a1a2e
surface-dim Sidebar/secondary surface #f0f0f0 #12122a
text Primary text #1a1a1a #e0e0e0
text-secondary Dimmed text #666666 #999999
border Default border color #e0e0e0 #333355
error Error/danger #ef4444 #ef4444
warning Warning/caution #f59e0b #f59e0b
success Success/positive #10b981 #10b981

Additional variables MAY be defined. Unknown variable names resolve to a renderer-chosen default.

9.3 Theme Files

Themes are stored as TOML files, trivially convertible to ui.theme messages:

# ~/.config/cosmix/themes/midnight.toml
[theme]
name = "midnight"
primary = "#6366f1"
primary-dim = "#4f46e5"
background = "#0f0f1a"
surface = "#1a1a2e"
surface-dim = "#12122a"
text = "#e0e0e0"
text-secondary = "#999999"
border = "#333355"
error = "#ef4444"
warning = "#f59e0b"
success = "#10b981"

9.4 Theme Switching

Sending a ui.theme message replaces the active theme. All panels re-resolve var() references and re-render. This is instant — no reload, no panel recreation.


10. Panel Lifecycle

10.1 State Machine

                    ui.panel (new id)
                         |
                         v
     [Created] -----> [Active] <----> [Updated]
                         |                ^
                         |                |
                         |     ui.panel (same id)
                         |     ui.style
                         |     ui.data
                         v
                     [Removed]
                    ui.remove

10.2 State Transitions

From Trigger To Effect
(none) ui.panel with new ID Active Create Wayland surface (if top-level), render markdown, attach script
Active ui.panel with same ID Active (updated) Replace body, merge headers. Preserve geometry/position if not in update.
Active ui.style Active (updated) Update style properties. Re-render affected elements.
Active ui.data Active (updated) Update data widget contents.
Active ui.remove Removed Destroy surface. Remove children (cascade). Stop attached script.
Active TTL expires Removed Same as ui.remove.

10.3 Orphan Handling

When a process disconnects from the hub, its panels are NOT automatically removed (the user may still be looking at them). However:

  • Event subscriptions for the disconnected process are deregistered.
  • Clicking action links in orphaned panels produces no response.
  • The hub MAY mark orphaned panels visually (dimmed, badge, etc.).
  • The hub SHOULD remove orphaned panels after a configurable timeout (default: 60 seconds).

10.4 TTL

The ttl header specifies auto-removal in milliseconds from panel creation time. When TTL expires, the panel is removed as if ui.remove were received.

By default, updating a panel does NOT reset its TTL. A panel created with ttl: 5000 that is updated at t=4s still expires at t=5s. To reset TTL on update, include ttl in the update message — this restarts the timer from the update time.

Commonly used for notifications:

---
command: ui.panel
id: toast-001
layer: notification
position: top-right
ttl: 5000
---
> ![check](lucide:check-circle) **Mail sent** to alice@example.com

11. Composition Model

11.1 Panel Hierarchy

Panels form a tree rooted at the implicit desktop panel:

desktop (implicit root)
├── launcher (parent: desktop)
├── mail-app (parent: desktop)
│   ├── mail-sidebar (parent: mail-app)
│   └── mail-content (parent: mail-app)
│       ├── email-list (parent: mail-content)
│       └── email-view (parent: mail-content)
├── statusbar (parent: desktop)
└── toast-001 (parent: desktop, layer: notification)

11.2 Top-Level Panels

Panels with parent: desktop (or no parent header) are top-level. Each creates a Wayland surface (window) on native desktop renderers, or a root <div> in WASM renderers.

11.3 Child Panels

Panels with parent: some-panel-id render inside their parent. The parent's layout header determines how children are arranged:

Layout Behavior
column Children stack vertically (default)
row Children arrange horizontally
grid Children flow into grid cells
stack Children overlap (last on top)

11.4 Container Panels and Layout Widgets

Layout widgets (Container, Tabs, SplitPane) from Section 6.2 do NOT nest children inside their markdown code block. Instead, complex layouts are composed from multiple panels using the parent header:

SplitPane ≠ a code block containing two regions
SplitPane = a container panel with layout:row, children are separate panels

This is a deliberate design choice. Markdown is a linear format — it cannot naturally express spatial layout like side-by-side panes. Rather than inventing non-GFM nesting syntax, the protocol uses panel composition: each region is a separate ui.panel message with parent pointing to the container. The container's layout header determines arrangement.

For complex multi-region layouts, layout: grid with grid_template (Section 3.1.1) allows a single panel to define named regions — reducing panel count for static layouts while keeping each region's content in a separate child panel. This is the recommended approach for application-level layouts (sidebar + toolbar + content + status bar).

A panel with layout headers but no body (or empty body) is a pure container — it provides structure without content:

---
command: ui.panel
id: mail-app
parent: desktop
layout: row
width: 100%
height: 100%
---

Child panels populate it:

---
command: ui.panel
id: mail-sidebar
parent: mail-app
width: 250
---
# Mailboxes
- Inbox (3)
- Sent
- Drafts

11.5 Layer Ordering

The layer header controls z-ordering across the display:

Layer Z-order Purpose
background Lowest Wallpaper, desktop widgets
normal Default Application windows
overlay Above normal Floating panels, tooltips
notification Highest Alerts, toasts

Within a layer, the most recently focused panel renders on top.

11.6 Reparenting

A panel can be moved to a different parent by sending ui.panel with the same id and a new parent header. The panel is removed from its old parent and inserted into the new one.


12. Data-Driven Widgets

12.1 Overview

VirtualList and DataTable widgets use a template + data model. The template defines how each data item renders. The data provides the content. This separation enables:

  • Virtual scrolling (only template-stamp visible items)
  • Incremental updates (add/remove/update individual items)
  • Re-templating without re-sending data

12.2 Template Syntax

Templates are markdown with {field} interpolation placeholders:

- ![{icon}](lucide:{icon}) **{name}** ({unread})

Given a data item {"icon": "inbox", "name": "Inbox", "unread": 3}, this produces:

- ![inbox](lucide:inbox) **Inbox** (3)

Rules:

  • {field} is replaced with the string value of the field from the data object.
  • Missing fields produce an empty string.
  • {{ produces a literal { in the output. }} produces a literal }.
  • No conditionals, no loops, no expressions — pure field substitution.
  • Templates are set via ui.template and persist until replaced.
  • If no template is set, the renderer SHOULD display each item's id as a list item.

12.3 Data Format

Data is sent via ui.data as a JSON array of objects. Every object MUST have an id field:

[
  {"id": "inbox", "name": "Inbox", "unread": 3, "icon": "inbox"},
  {"id": "sent", "name": "Sent", "unread": 0, "icon": "send"}
]

12.4 Incremental Updates

Action Headers Body Effect
replace (default) target JSON array Replace all data
insert target, index JSON object Insert at position
update target, item JSON object Replace entire item with matching ID
patch target, item Partial JSON object or JSON-Patch array Merge fields into item with matching ID
remove target, item (empty) Remove item with matching ID
clear target (empty) Remove all data

12.4.1 Patch: Partial Updates

The patch action updates individual fields on an existing data item without replacing the entire object. This is the preferred update mechanism for live data — it minimizes payload size and avoids race conditions where a full update could overwrite concurrent changes to other fields.

Merge-patch (default): The body is a partial JSON object. Fields present in the patch are merged into the existing item. Fields absent are preserved. Fields set to null are removed.

---
command: ui.data
target: email-list
action: patch
item: msg-42
---
{"unread": false, "labels": ["important", "work"]}

This updates only unread and labels on item msg-42. All other fields (from, subject, date, etc.) are preserved untouched.

JSON-Patch (RFC 6902): For operations that merge-patch cannot express (array manipulation, moves, tests), the body MAY be a JSON-Patch array. Renderers distinguish the two formats by checking whether the body is an array of {"op":...} objects or a plain object:

---
command: ui.data
target: email-list
action: patch
item: msg-42
---
[
  {"op": "replace", "path": "/unread", "value": false},
  {"op": "add", "path": "/labels/-", "value": "urgent"}
]

When to use each action:

Scenario Action Why
Initial load replace Full dataset, clean slate
New item arrives (e.g., new email) insert Add without touching existing items
One field changes (e.g., read status) patch Minimal payload, no overwrite risk
Item fully rewritten (e.g., edited draft) update Full object replacement is intentional
Item deleted remove Remove by ID
Bulk field update across many items Multiple patch in ui.batch Atomic multi-item partial update

In a typical SPA-like workflow, the process loads data with replace once, then uses patch for all subsequent mutations. Full replace is reserved for navigation events (switching mailboxes, changing directories) where the entire dataset changes.

12.5 Tree Data

VirtualList with indent: true supports hierarchical data. Items include depth (integer) and expandable (bool) fields:

[
  {"id": "docs", "name": "Documents", "depth": 0, "expandable": true, "icon": "folder"},
  {"id": "notes", "name": "notes.md", "depth": 1, "expandable": false, "icon": "file-text"},
  {"id": "pics", "name": "Pictures", "depth": 0, "expandable": true, "icon": "folder"}
]

Expanding/collapsing emits expand/collapse events. The process is responsible for inserting/removing child items via ui.data.


13. Transport Tiers and Degradation

13.1 Transport Matrix

Tier Transport Protocol Scripts Latency
Local Unix socket Full Yes <1ms
Mesh WebSocket over WireGuard Full Yes ~10ms
Federated (SMTP) Email + MIME Markdown only No seconds–minutes
Federated (WebSocket) Direct WebSocket Display + events Sandboxed ~100ms

13.2 SMTP Wire Format

AMP panels sent via SMTP use the text/x-amp-panel MIME content-type. Senders MUST include a text/plain multipart alternative:

Content-Type: multipart/alternative; boundary="cosmix-boundary"

--cosmix-boundary
Content-Type: text/plain; charset=utf-8

[Plain text rendering of the markdown body]

--cosmix-boundary
Content-Type: text/x-amp-panel; charset=utf-8

---
command: ui.panel
id: remote-status
source_peer: mark@cosmix.mesh
permissions: display
---
# Server Status
| Service | Status |
|---------|--------|
| maild | running |
| noded | running |

[Refresh](amp-reply:status.refresh)

--cosmix-boundary--

13.3 Degradation Rules

When a renderer does not support a feature, it degrades gracefully:

Feature Degradation
~~~mix code blocks Stripped (not rendered)
Interactive widgets (code blocks) Rendered as their text content or placeholder
Action links Rendered as plain text with URI visible
var() references Renderer default colors
VirtualList/DataTable First N items as plain list/table
Icons (lucide:name) Rendered as [name] text
Themes Ignored (renderer defaults)
SplitPane/Tabs Contents rendered sequentially

13.4 Conformance and Degradation

A Level 0 renderer receives a full ui.panel message with interactive widgets and scripts. It ignores the code block widgets (renders them as code), ignores scripts (strips ~~~mix), and renders the remaining markdown. The result is readable, static, and useful — not broken.


14. Conformance Levels

14.1 Levels

Level Name Required Capabilities
0 Markdown GFM rendering. ui.panel create/update/remove lifecycle. Headings, paragraphs, lists, tables, blockquotes, rules, inline formatting, images. Code blocks as monospace text. Links as clickable text.
1 Interactive Level 0 + code block widget recognition (TextInput, TextArea, Button, Checkbox, Toggle, Dropdown, Slider). ui.event emission on user interaction. Action link dispatch. Focus management.
2 Data Level 1 + VirtualList, DataTable. ui.template + ui.data commands. Incremental data updates. Virtual scrolling for large datasets.
3 Scripted Level 2 + Mix script execution (inline, referenced, standalone). on/off event handlers. ui.subscribe/ui.unsubscribe hub commands.
4 Federated Level 3 + text/x-amp-panel MIME parsing. amp-reply/amp-request action URIs. Permission prompts per sender. DKIM/SPF/DMARC validation. Visual sandbox for federated panels.

14.2 Renderer Classification

Renderer Expected Level
Native desktop (winit+wgpu) Level 4
WASM browser client Level 3
TUI terminal client Level 1
Headless/testing Level 3 (no rendering, full protocol)
Non-cosmix email client Level 0 (reads text/plain fallback)

14.3 Conformance Requirements

A renderer at Level N MUST support ALL capabilities of Levels 0 through N. It MUST gracefully degrade features from higher levels (not crash, not produce garbled output).


15. Security Model

15.1 Trust Domains

Domain Transport Trust Level
Local Unix socket Full — unrestricted AMP access
Mesh WebSocket over WireGuard /24 Full — same trust as local (single owner)
Federated SMTP or direct WebSocket Sandboxed — restricted capabilities

A WireGuard /24 network IS the trust boundary. All nodes within it belong to the same person. Mesh membership IS the credential. No per-widget ACLs, no capability tokens within a mesh.

15.2 Origin Tracking

Every AMP message SHOULD carry a from header identifying the source. For federated messages, the source_peer header identifies the originating mesh.

source_peer flow: The receiving hub sets source_peer when a message arrives from an external transport (SMTP gateway, federated WebSocket). Local processes MUST NOT set source_peer themselves — the hub overwrites any process-supplied value. In SMTP delivery, the sending mesh's gateway composes the MIME message; the receiving mesh's hub parses it and injects source_peer based on the validated sender identity (DKIM signature).

15.3 Federated Panel Restrictions

Capability Local/Mesh Federated
Display panels yes yes (with permission prompt)
Execute scripts yes NO
launch: actions yes NO
File system access yes NO
Config writes yes NO
xdg-open: URLs yes confirmation dialog
Style own panels yes yes
Style other panels yes NO

15.4 Permission Prompts

First-time display of a federated panel triggers a permission prompt:

"mark@cosmix.mesh wants to display a panel."
[Allow always] [Allow once] [Block sender]

DKIM signature validation is REQUIRED for SMTP-delivered panels. Invalid or missing signatures → panel rejected silently. SPF and DMARC alignment SHOULD be checked.

15.5 Visual Sandbox

Federated panels MUST render with a visual indicator of their origin: - A distinct border, badge, or banner showing the source_peer address. - Federated panels MUST NOT overlay local panels. - Federated panels MUST NOT impersonate system UI (desktop, statusbar, notifications).


16. Examples

Note: Examples below show AMP message content without the ---\nEOM\n stream terminator for readability. On the wire, every message ends with ---\nEOM\n per 01_amp-wire-protocol.md §5.1.

16.1 Minimal Panel — Status Dashboard

A simple dashboard with no script. Pure markdown content + AMP headers.

---
command: ui.panel
id: status
parent: desktop
title: System Status
width: 400
height: 300
---
# System Status

| Metric | Value |
|--------|-------|
| CPU | 12% |
| RAM | 4.2 / 16 GB |
| Disk | 120 / 500 GB |
| Uptime | 5d 14h |

> ![check](lucide:check-circle) All services running

---

[Refresh](status.refresh) [Details](ui.panel:status-detail)

Layers used: Markdown (content), AMP headers (window properties). No script.

16.2 Form with Inline Script — Email Compose

A form with interactive widgets and an inline Mix script for handling submission.

---
command: ui.panel
id: compose
parent: desktop
title: Compose
width: 500
height: 600
layout: column
collect_values: true
---
# New Message

~~~textinput id=to placeholder="To..."
~~~

~~~textinput id=subject placeholder="Subject"
~~~

~~~textarea id=body rows=15
~~~

[Send](compose.send) [Attach](compose.attach) [Discard](compose.discard)

~~~mix
on ui.event from "compose" action "compose.send"
  send "maild" send to=$event.to subject=$event.subject body=$event.body
  send "ui" remove target="compose"
  emit "ui" panel id="toast" layer="notification" ttl="3000" +
       body="> ![check](lucide:check-circle) **Sent** to " .. $event.to
end

on ui.event from "compose" action "compose.discard"
  send "ui" remove target="compose"
end
~~~

Layers used: All three. Markdown for labels and structure. Headers for window geometry. Mix for send/discard logic.

16.3 Data-Driven List — Mail Client Sidebar

A VirtualList with template and data binding, driven by a standalone Mix script.

#!/usr/bin/env mix
-- Mail sidebar — standalone script
address "ui"

-- Create the sidebar panel
send "panel" id="mail-sidebar" parent="mail-app" width="250" +
     background="var(surface-dim)" scrollable="true"

-- Set template for mailbox items
send "template" target="mail-sidebar" +
     body="- ![{icon}](lucide:{icon}) **{name}** ({total})"

-- Load and push data
$mailboxes = send "maild" mailbox.list
send "data" target="mail-sidebar" body=$mailboxes

-- Handle selection
on ui.event from "mail-sidebar" action "select"
  $emails = send "maild" email.list mailbox=$event.item limit=50
  send "data" target="email-list" body=$emails
end

16.4 Desktop Composition — Full Desktop Layout

Multiple processes composing the desktop:

Process: cosmix-noded (startup)
├── ui.panel id=launcher parent=desktop position=left width=15%
├── ui.panel id=statusbar parent=desktop position=bottom height=2rem
└── ui.theme name=midnight

Process: mail-script.mx
├── ui.panel id=mail-app parent=desktop layout=row
├── ui.panel id=mail-sidebar parent=mail-app width=250
├── ui.panel id=mail-list parent=mail-app width=auto
└── ui.subscribe source=mail-sidebar

Process: monitor-service
├── ui.panel id=statusbar body="![cpu](lucide:cpu) `12%` | ..."
└── (updates statusbar every 5 seconds)

Each process sends its panels independently. The display service composes them. No central coordinator.

16.5 Federated Dashboard — SMTP Delivery

An AMP panel sent via email with graceful degradation:

From: mark@cosmix.mesh
To: sally@herserver.com
Subject: Nightly Status Report
Content-Type: multipart/alternative; boundary="cosmix"

--cosmix
Content-Type: text/plain; charset=utf-8

Nightly Status Report

Server   | CPU  | RAM      | Disk
---------|------|----------|----------
web-01   | 12%  | 4.2 GB   | 120 GB
db-01    | 45%  | 12.8 GB  | 234 GB
api-01   | 8%   | 2.1 GB   | 89 GB

All services running. Last updated: 2026-04-06 22:00 AEST

--cosmix
Content-Type: text/x-amp-panel; charset=utf-8

---
command: ui.panel
id: nightly-status-mark
source_peer: mark@cosmix.mesh
permissions: display
---
# Nightly Status — 2026-04-06

| Server | CPU | RAM | Disk |
|--------|-----|-----|------|
| web-01 | 12% | 4.2 GB | 120 GB |
| db-01 | 45% | 12.8 GB | 234 GB |
| api-01 | 8% | 2.1 GB | 89 GB |

> ![check](lucide:check-circle) All services running

---

[Acknowledge](amp-reply:status.ack) [Request Details](amp-reply:status.detail)

--cosmix--

Sally's cosmix-mail renders the interactive panel. Thunderbird renders the plain text fallback. Both are readable.


Appendix A: Header Reference

Alphabetical listing of all headers recognized by the display protocol.

Header Type Commands Description
action string ui.data Data operation: replace, insert, remove, update, clear
align enum ui.panel Child alignment: start, center, end, stretch
background color ui.panel, ui.style Background color or var(name)
border_color color ui.panel, ui.style Border color
border_radius float ui.panel, ui.style Corner rounding (rem)
border_width float ui.panel, ui.style Border thickness (px)
collect_values bool ui.panel Include all widget values in event payloads
command string all Command identifier
decorations list ui.panel CSD decorations (comma-separated)
font_size float ui.panel, ui.style Base font size (rem)
from string all Source address
gap float ui.panel Space between children (rem)
grid_area string ui.panel Named grid region this child occupies
grid_rows string ui.panel Grid row track definitions
grid_template string ui.panel Grid column track definitions
height size ui.panel Panel height
id string ui.panel Panel identifier
index integer ui.data Row index for insert
item string ui.data Item ID for update/remove
layer enum ui.panel Z-layer: background, normal, overlay, notification
layout enum ui.panel Child layout: column, row, grid, stack
msg_id string all Message identity for request/response correlation (distinct from id)
name string ui.theme Theme name
opacity float ui.panel, ui.style Panel opacity (0.0–1.0)
overflow enum ui.panel Overflow behavior: clip, scroll, visible
padding float/quad ui.panel Inner padding (rem)
parent string ui.panel Parent panel ID
permissions string ui.panel display for federated panels
position enum/coord ui.panel Panel position
script string ui.panel Mix script path/URI
scrollable bool ui.panel Enable scroll container
source string ui.event Panel ID where event originated
source_peer string ui.panel Originating mesh peer (set by transport)
sticky bool ui.panel Survives workspace switches
target string ui.style, ui.remove, ui.data, ui.template Target panel/widget ID
text_color color ui.panel, ui.style Default text color
title string ui.panel Window title
ttl integer ui.panel Auto-remove timeout (ms)
width size ui.panel Panel width

Appendix B: Widget Quick Reference

Widget Aliases Category Key Properties Key Events
Label label Display text_color, font_size, align
Icon icon Display name, size, color
Image image, img Display src, alt, fit
Markdown markdown, md Display Action clicks
Container container, div Layout layout, gap, padding
ScrollContainer scroll Layout direction, max_height scroll
Tabs tabs Layout tabs, active, closable select, close
SplitPane splitpane, split Layout direction, ratio, min_size resize
Button button, btn Input label, variant, disabled click
TextInput textinput, input Input value, placeholder, password input, submit, focus, blur
TextArea textarea Input value, rows, placeholder input, focus, blur
Dropdown dropdown, select Input options, value change
Checkbox checkbox Input label, checked change
Toggle toggle, switch Input label, checked change
Slider slider, range Input min, max, value, step input, change
RadioGroup radiogroup, radio Input options, value, direction change
Progress progress Input value, max, label, variant
VirtualList virtuallist, vlist Data item_height, indent, selectable select, activate, expand, collapse
DataTable datatable, table Data columns, sortable, sort_column select, activate, sort
MenuBar menubar Chrome menus, caption_buttons invoke
ContextMenu contextmenu Chrome items, position invoke, close
Dialog dialog, modal Chrome title, closable, backdrop close

Appendix C: Event Payload Reference

All ui.event messages include these base fields:

Field Type Always present Description
action string Yes Event type
widget string When widget-level Widget ID

Additional fields by event action:

Action Fields Emitted by
click Button, action links
select item, index, value VirtualList, DataTable, Tabs, list items
activate item, index VirtualList, DataTable (double-click)
input value TextInput, TextArea, Slider (continuous)
change value or checked Dropdown, Checkbox, Toggle, Slider (final)
submit value TextInput (Enter key)
focus TextInput, TextArea
blur value TextInput, TextArea
expand item VirtualList (tree)
collapse item VirtualList (tree)
sort column, order DataTable
resize ratio SplitPane
scroll offset, max or visible ScrollContainer, VirtualList
invoke item MenuBar, ContextMenu
close Tabs (tab close), ContextMenu, Dialog
toggle checked Task list checkboxes in markdown
change (radio) value RadioGroup

Appendix D: Theme Variable Reference

See Section 9.2 for the standard variable set. Variables are arbitrary strings. This appendix lists the complete recommended set:

Core palette: primary, primary-dim, background, surface, surface-dim

Text: text, text-secondary

Borders: border

Semantic: error, warning, success

Extended (optional): info, muted, accent, highlight, selection, focus-ring

All variables resolve via var(name) in style property values. Resolution is a single flat lookup — no fallback chains, no cascade, no computed values.

6 Model
Chapter 6

Cosmix Display Model

version 0.3.0status draftdate 2026-04-18

The Cosmix Display Model


Core Principles

Cosmix is a unified display model for mesh-addressable applications on a modern Linux/Wayland baseline.

  • Applications own state; the display service is stateless. The process is the single source of truth for all application data, IDs, and mutation logic. The display service draws what it receives and reports user interactions. There is no virtual DOM, no reconciliation engine, no framework-managed lifecycle.
  • AMP is the application protocol; postcard is the hot-path protocol. AMP (human-readable markdown frontmatter) for everything apps touch. Postcard (binary, Rust-to-Rust) only at the compositor boundary where latency matters.
  • Widgets compose from a fixed registry using declarative markdown. No runtime extensibility, no plugin widgets. New widget types require a spec version bump.
  • The visual language is token-based, tight, and information-dense. Amiga aesthetic: every pixel earns its place. No shadows, no gradients, no whitespace for whitespace's sake.
  • The mesh is a first-class dimension. A process on one node can create panels on another node's display. The protocol doesn't know or care which renderer is drawing the panel.
  • Mix is the scripting language. ARexx-inspired, AMP-native, with send, address, emit, and on keywords. Mix scripts are the primary way non-daemon applications drive the display.

0. Purpose

The AMP Display Protocol Specification defines what messages look like, what commands exist, and what widgets are available. This document defines something different: how to think about building cosmix desktop experiences.

It names the concepts that all widgets, panels, and apps are built against. It establishes the interaction grammar, the visual vocabulary, the rendering model, and the composition rules. A new widget is designed by reading this document. An existing widget is audited against this document. An app is structured according to the patterns this document describes.

This is the Intuition to the protocol spec's ROM Kernel Reference. The protocol spec tells you what bytes to send. This document tells you what those bytes mean and how they compose.


1. The Stack

The cosmix display stack has four layers. Each layer has clear responsibilities and a defined boundary with the layers above and below it.

+-------------------------------------------------+
|  Layer 4: Applications                          |
|  Mix scripts, Rust daemons, mesh peers          |
|  Own all state. Speak AMP. Drive the display.   |
+-------------------------------------------------+
|  Layer 3: Display Service (cosmix-deskd)        |
|  Renders panels. Reports interactions. No state.|
|  Speaks AMP to apps, renders to surfaces.       |
+-------------------------------------------------+
|  Layer 2: Window Management (cosmix-shell)      |
|  Placement, stacking, focus policy, workspaces. |
|  Speaks postcard to compositor, AMP to apps.    |
+-------------------------------------------------+
|  Layer 1: Compositor (cosmix-comp)              |
|  DRM/KMS, input, surface compositing.           |
|  Pure mechanism. Zero policy.                   |
|  Speaks Wayland to clients, postcard to shell.  |
+-------------------------------------------------+

Current state: Layers 1-2 are deferred (running on cosmic-comp). Layer 3 (deskd) is the active implementation. Layer 4 is what apps build against.

Layer boundaries

Boundary Protocol Frequency Why this protocol
App <-> Display Service AMP Low-medium (UI events, data pushes) Human-readable, debuggable, mesh-native
Display Service <-> Shell AMP Low (placement requests, workspace) Orchestration, not hot path
Shell <-> Compositor Postcard binary High (pointer 60-165Hz, frame callbacks) Latency-critical, Rust-to-Rust
Compositor <-> Kernel Wayland/DRM Hardware Standard Linux graphics stack

What each layer does NOT do

  • Compositor does not decide where windows go, which is focused, or what stacking order means. It composites surfaces where it's told.
  • Shell does not render widget content. It positions and layers panels.
  • Display service does not own application state, generate IDs, or make decisions about what data to show. It renders what it receives.
  • Applications do not position their own windows or manage z-order. They declare panels and let the shell handle placement.

The Amiga lesson

AmigaOS Intuition was simultaneously the compositor, window manager, and widget toolkit — coherent because it was one library in one address space with shared data structures. Cosmix necessarily splits these across process boundaries.

The lesson from Intuition is not "put everything in one address space" but "design for coherence, then implement it." Wayland's process boundaries don't prevent coherence; they require it to be engineered explicitly through shared vocabulary (this spec), clean protocols at boundaries (AMP + postcard), a consistent visual system (Section 5), and unified event flow (Section 7).

See 00_index.md for the full Amiga mapping and phasing roadmap.

Patterns worth emulating

  • IDCMP-style universal event queue. Every event, regardless of source, arrives at the same port in the same shape. Shell events (workspace switched, panel closed) and display service events (widget interaction) both arrive through AMP. Apps don't have two event streams.
  • Screens and Windows. Workspaces as Screens, panels as Windows. Apps say "this panel belongs to context X" rather than "position me at X,Y." This maps naturally onto Wayland's output/surface model.
  • Shared vocabulary in protocol, not code. Intuition's structs (Window, Screen, Gadget) were shared memory across all client code. The AMP Display Protocol types are the cosmix equivalent — shared vocabulary marshaled across process boundaries. If shell and display service invent their own words for the same concepts, coherence is lost.

The anti-pattern: Boopsi extensibility

Later Amiga versions added Boopsi (Basic Object-Oriented Programming System for Intuition) — a class-based widget system where third parties could extend the widget registry. cosmix goes the opposite direction: no runtime widget extensibility, all extensibility at the app level via AMP. New widget types require a spec version bump and a renderer update.

This is correct for mesh-addressable UIs. A renderer on one node must produce identical output to a renderer on another node for the same ui.panel message. Runtime widget plugins would break this guarantee. The fixed registry is a feature, not a limitation — it's what makes remote panels and cross-node rendering possible.

The unavoidable cost

Amiga Intuition was fast partly because window management, rendering, and event dispatch were function calls within one address space. cosmix accepts the cost Wayland imposes — every pointer event crosses at least one process boundary, and the path from "user clicked" to "app received event" touches the compositor, potentially the shell, and the display service.

The mitigation is the postcard-vs-AMP split: hot path is postcard (binary, 60-165Hz), orchestration is AMP (human-readable, low frequency). The target is not "as fast as Amiga Intuition" but "fast enough that users don't notice the difference" — which is achievable and sufficient on modern hardware.

Current architectural state

deskd currently runs on winit, which mediates the Wayland client relationship with cosmic-comp. winit is temporary — it assumes "I am an application wanting a window," not "I am a display service hosting multiple surfaces." When cosmix-shell exists, deskd will likely drop winit and speak directly to the shell. Until then: don't build features that depend on winit's model in ways that will be painful to migrate. Panel lifecycle and input event flow should feel like cosmix abstractions with winit as implementation detail.

See 00_index.md for the full phasing roadmap and Rust ecosystem mapping.


2. Core Concepts

2.1 Surfaces

A surface is a rectangular region that receives rendering and input. In cosmix, surfaces map to Wayland surfaces at the compositor level and to panels at the display level.

Every surface has: - An identity (panel ID, assigned by the owning process) - A size (width x height, in logical pixels) - A position (assigned by the shell, not the app) - A layer (normal, overlay, notification — determines stacking class, per 05_amp-display-protocol.md §3.1) - An owner (the process that created it via ui.panel)

Surfaces are not windows in the traditional sense. They have no inherent chrome — title bars, scrollbars, status bars, resize handles are rendered by the display service as part of the surface content, not by the compositor.

2.2 Widgets

A widget is an interactive element within a surface. Widgets are declared in the markdown body of a ui.panel message using fenced code blocks:

~~~datatable id=files columns="Name,Size" selectable=multi
~~~

Every widget has: - A type from the fixed registry (Section 6 of the protocol spec) - An ID (assigned by the process, used for targeting commands and events) - Properties (declared in the code block's props string) - Content (the code block body, if any)

Widgets do not exist independently. They exist within a surface, positioned by the surface's layout flow. They cannot overlap, cannot float, cannot position themselves absolutely (with the exception of overlays like dropdowns and tooltips, which are rendered by the display service outside the normal flow).

2.3 Gadgets

A gadget is the interactive region of a widget — the clickable, draggable, focusable area that responds to user input. This is the Amiga term, and it captures an important distinction: the widget is the declaration (markdown + properties), the gadget is the rendered interactive element.

Every gadget has: - A bounding box (x, y, width, height in content space) - An action (what happens when activated — an ActionUri) - Optional tooltip text - An owner panel ID (for routing events back to the source process)

Gadgets are generated during rendering and stored as HitRects. They are ephemeral — rebuilt on every render pass. There is no persistent gadget tree. This is an immediate-mode pattern: the widget declaration is the source of truth, and the gadgets are derived.

2.4 Events

An event is a user interaction reported from the display service to the owning process. Events flow in one direction: display -> process (via AMP hub).

Every event has: - A source (panel ID) - A widget (widget ID that was interacted with) - An action (the type of interaction: select, activate, sort, submit, change) - A payload (action-specific data: selected item IDs, sort column, input value)

Events are fire-and-forget. The display service does not wait for a response. The process decides what to do (update data, navigate, open a dialog) and sends new commands if the UI needs to change.

2.5 Commands

A command is a directive from a process to the display service. Commands flow in one direction: process -> display (via AMP hub).

The command vocabulary is fixed (Section 3 of the protocol spec). Commands are: - Declarative (ui.panel — "this is what the UI should look like") - Incremental (ui.data — "here is new/changed data for this widget") - Targeted (ui.style, ui.set — "change this specific property") - Batchable (ui.batch — "apply these commands atomically")

Commands are idempotent where possible. Sending the same ui.panel twice produces the same result. ui.data action=replace with the same data is a no-op from the user's perspective.


3. The Interaction Model

3.1 The Universal State Machine

Every interactive gadget follows the same state machine:

         +----------+
    +--->|  Idle    |<--------------------+
    |    +----+-----+                     |
    |         | cursor enters             |
    |    +----v-----+                     |
    |    | Hovered  |--cursor leaves------+
    |    +----+-----+                     |
    |         | press                     |
    |    +----v-----+                     |
    |    | Pressed  |--drift > threshold--+
    |    +----+-----+     (cancel)        |
    |         | release                   |
    |    +----v-----+                     |
    |    | Activated|--fire event---------+
    |    +----------+
    |
    |  Drag variant (split pane, column resize, scrollbar):
    |    Pressed -> Dragging -> Released
    |    Dragging fires continuous updates, not single event
    |
    |  Double-click variant (datatable activate, title bar maximize):
    |    Activated -> check timing -> if < 400ms since last -> DoubleActivated
    +----------------------------------------------------------

Rules: - Hover is visual-only (cursor change, highlight). No event to the process. - Press captures the gadget. Release on the same gadget fires activation. - Drift beyond 4 scaled pixels between press and release cancels the click. - Drags bypass the activation path — they fire continuous updates during motion. - Double-click is detected release-to-release, 400ms window, 4px tolerance. - Right-click opens a context menu (if the panel has one). No event to process.

3.2 Focus

Focus determines which widget receives keyboard input.

  • One focused widget per panel. Focus is panel-scoped, not global.
  • Focusable widgets: TextInput, TextArea, DataTable (focused row), VirtualList in tree mode (focused node), and any widget that declares keyboard interactions.
  • Focus is set by clicking a focusable widget, or programmatically via ui.set with a focus: true property on the target widget.
  • Focus is shown as a 2px outline ring in the link color around the focused widget's bounding box.
  • Tab cycles through focusable widgets in render order. There is no explicit tab-order property (add one if this becomes a problem).
  • Escape clears focus.

Widget-specific focus behavior: - TextInput/TextArea: receives character input, arrow keys, Home/End, Backspace/Delete. Enter submits (TextInput) or inserts newline (TextArea). - DataTable: arrow keys move the focused row. Enter activates. Space toggles selection. Shift+arrow extends selection. - VirtualList (tree mode): arrow keys navigate nodes. Enter activates. Left/Right collapse/expand. Space toggles selection (if selectable).

3.3 Keyboard Shortcuts

Keyboard shortcuts have two layers:

Menu-declared shortcuts are the primary mechanism. A process declares shortcuts as menu item properties:

{"label": "Copy", "action": "app.copy", "shortcut": "Ctrl+C"}

The display service matches keyboard input against menu item shortcuts regardless of whether the menu is visible. This gives every panel a keyboard-shortcut namespace tied to its menu structure.

Widget-scoped keyboard bindings handle interactions that are not naturally menu items: arrow keys in a DataTable, Space to toggle selection, Ctrl+Click for multi-select. These are handled by the display service directly as part of the focused widget's behavior, not dispatched as events unless they produce a state change (e.g., row selection change fires a select event).

3.4 Drag Operations

Drag operations are a display-service-internal concern. They modify rendering state (split ratio, column width, scroll position) without sending events to the process.

Current drag types: - Split pane divider — updates split ratio in widget state - Column resize — updates column width in datatable state - Scrollbar thumb — updates scroll position - Window title bar — delegates to compositor (drag_window) - Window resize edges — delegates to compositor (drag_resize_window)

3.5 Item Drag (Future)

Cross-widget item drag (e.g., dragging files between panes in a file manager) is explicitly deferred. When implemented, it will require:

  • Drag source capability on DataTable/VirtualList
  • Drag ghost rendering (semi-transparent item representation at cursor)
  • Drop target detection with visual feedback
  • Modifier key handling (Ctrl=copy, Shift=move)
  • Autoscroll near container edges
  • A ui.drop event type in the protocol
  • Drag cancellation (Escape key, release off-target)

Estimated cost: 600-900 lines of display service code.

For file managers: keyboard operations (F5=copy, F6=move) and toolbar buttons are the preferred interaction pattern. They are faster once learned, require zero new infrastructure, and match the Amiga DOpus heritage.


4. The Layout Model

4.1 Flow Layout

Widgets are laid out in vertical flow within a panel. Each widget occupies the full available width and reports its rendered height. The next widget starts below the previous one.

Horizontal arrangement is achieved through: - SplitPane — divides available width into two regions - Container with layout=row — arranges children horizontally - Grid layout (specified in protocol spec Section 5) — track-based layout with named regions

4.2 Spacing Conventions

All spacing scales with the display's DPI factor (scale_factor).

Context Spacing Scaled
Between widgets 2px x scale Yes
Widget internal padding Widget-specific Yes
SplitPane divider 3px line + 4px gap each side Yes
Scrollbar width 6px + 2px margin Yes
Status bar height 24px Yes
Menu bar height Computed from text metrics Yes

4.3 Sizing

Panels declare their size intent via width and height headers: - Pixel values: width=800 - Percentage of screen: width=50% - Auto: width=auto (size to content, up to screen bounds)

Widgets declare their size intent via properties: - Column widths: width="80px", width="30%", width=auto - Fixed heights: rare (DataTable row height = font size + padding)

The display service resolves sizes using taffy (Flexbox-like layout engine). The compositor/shell may further constrain sizes based on screen geometry.


5. The Visual Design System

5.1 Color Tokens

The display service uses a semantic color system. Colors are referenced by role, not by value. Themes assign values to roles.

Token Role Default (dark)
background Surface background #1e1e2e
text Primary text #cdd6f4
text-dim Secondary/muted text #a6adc8
heading Heading text Same as text
link Actions, focus rings, active elements #89b4fa
rule Borders, separators, dividers #45475a
code-bg Code block, header backgrounds #313244
error Destructive actions, error states #f38ba8
success Confirmation, positive states #a6e3a1
warning Caution states #f9e2af

Rules for widget authors: - Use text for primary content, text-dim for secondary/metadata. - Use link for anything clickable or focused. - Use rule for any structural line (borders, separators, dividers). - Use code-bg for elevated surfaces (headers, selected rows, toolbars). - Use error/success/warning only for semantic states, never for decoration. - Derive colors from tokens using alpha blending, not by introducing new hardcoded values.

5.2 Typography

Text rendering uses cosmic-text with system font discovery.

Style Usage Size
Body Default text in paragraphs, lists, table cells theme.font_size (default 14px)
Small Metadata, timestamps, secondary info font_size - 2
Tiny Keyboard shortcuts, badges, fine print font_size - 4
Heading 1 Page/panel titles font_size + 8, bold
Heading 2 Section headers font_size + 4, bold
Heading 3 Subsection headers font_size + 2, bold
Monospace Code blocks, data values, paths System monospace, same size as body

Inline text styling: Markdown inline formatting (bold, italic, code, strikethrough, links) is a first-class primitive. Widgets that render text content must support mixed-style runs within a paragraph — bold words, inline code spans, colored links. This is the foundation for rich content in Mix shell output, indexd result snippets, and maild message bodies.

Rules for widget authors: - Use body size for all primary content. - Use small size for supporting text (column headers, status bar). - Never hardcode font sizes — derive from theme.font_size x scale. - All text sizes scale with DPI. There is no separate logical/physical pixel model; use f32 throughout and multiply by scale_factor. - Support inline formatting in any widget that renders user-facing text content.

5.3 Iconography

Icons are a load-bearing part of the visual system. They communicate file types, actions, and states faster than text.

Icon system: FreeDesktop icon theme (system-installed). Icons are referenced by semantic name against the standard naming spec, not by path.

Context Size (logical px) Usage
Inline 16 Within text, table cells, breadcrumbs
Toolbar 24 Button bars, action buttons
Large 32 File type indicators, empty states

Rendering: Monochrome icons follow the text color (via alpha masking) to match the theme. Full-color icons (file type thumbnails, app icons) render at native color.

Referencing icons in widgets: Icons are referenced by name in widget properties and data fields. The display service resolves names to the current theme's icon files.

{"id": "readme", "icon": "text-x-generic", "name": "README.md"}

The DataTable format property supports an icon type for columns that render icons from the data field value:

{"key": "icon", "label": "", "width": "24px", "format": "icon"}

Fallback: If an icon name is not found in the system theme, the display service renders a Unicode glyph fallback (folder emoji for directories, document emoji for files). This preserves functionality on systems without FreeDesktop icon themes.

5.4 Elevation and Depth

The display service has a flat visual model — no shadows, no drop-shadows, no elevation levels. Depth is communicated through:

  • Background color steps: background -> code-bg (lighter) for elevated surfaces like headers and toolbars
  • Borders: rule color for structural boundaries
  • Z-ordering: overlays (dropdowns, tooltips, context menus) paint last, on top of everything

Rules for widget authors: - Do not add shadows or gradients. - Use code-bg for surfaces that need to appear elevated above background. - Overlays are painted after the main content pass, not within the flow.

5.5 Interactive States

Every interactive element has these visual states:

State Visual treatment
Default Token colors, no highlight
Hovered Background alpha increase (30 -> 80 for buttons), cursor change
Pressed No distinct visual (press is transient; release fires action)
Focused 2px outline ring in link color
Selected Semi-transparent link background (alpha ~30%)
Disabled text-dim color, no cursor change, no hover effect

Rules for widget authors: - Hover feedback is mandatory for all clickable elements. - Selection highlight uses link color at ~30% alpha, consistently. - Do not invent new highlight colors or patterns per widget. - Focus ring is drawn by the display service, not by the widget renderer.

5.6 Density

The display service has one density level. There is no compact/comfortable/ spacious toggle. Spacing is tight by default (2px between elements, minimal padding) reflecting the information-dense Amiga aesthetic.

If density controls are added in future, they will be a global multiplier on the spacing table in Section 4.2, not per-widget overrides.

5.7 Theming

Themes are defined as named sets of color token assignments. The display service ships with one built-in dark theme (Catppuccin Mocha-derived). Additional themes are applied via the ui.theme command at runtime.

Theme definition: A theme is a set of key-value pairs mapping token names to ARGB hex values. Themes are applied globally — all panels update when the theme changes. There are no per-panel themes.

Per-panel style overrides: Individual panels may override specific style properties via the ui.style command or inline style headers on ui.panel. These override the theme for that panel only.

User themes: Future. A themes.toml file in ~/.config/cosmix/ will allow user-defined themes. The runtime ui.theme command is the mechanism; the file is just persistence.


6. The Rendering Architecture

6.1 Rendering Model

The display service uses a dirty-flag immediate-mode rendering model. Widgets are pure functions of state — they render from their declaration (markdown + properties) and the current widget state maps. No widget owns persistent rendering state beyond what is stored in the shared state maps.

Key properties: - Full re-render on dirty. When a panel's data, theme, or size changes, its entire content is re-rendered from the cached markdown body. - Scroll without re-render. Content is rendered to an oversized off-screen buffer. Scrolling is pure pixel blitting — no geometry recalculation. - Hit rects are ephemeral. Interactive regions are generated during rendering and discarded on the next render. There is no persistent gadget tree.

6.2 Z-Ordering

Content renders in this order (back to front):

  1. Panel background
  2. Menu bar (fixed at top, not scrolled)
  3. Widget content (scrolled)
  4. Scrollbar track and thumb
  5. Focus ring
  6. Dropdown menus
  7. Context menus
  8. Tooltips

Items 5-8 are overlays — they paint after the main content pass and are not part of the flow layout.

6.3 Hit Testing

Hit rects are in content space (absolute y, before scroll adjustment). When testing a cursor position, the display service converts window-space y to content-space y by adding scroll_y.

Hit rects are tested in render order (first match wins). There is no z-index or priority system — overlays are tested first because they are rendered last and their hit rects appear at the end of the list.

6.4 The Widget Rendering Contract

Every widget renderer must:

  1. Accept the standard parameter set — pixmap, text renderer, theme, position, scale, hit rects, and all state maps.
  2. Render within its allocated width (max_width). May use less but must not exceed.
  3. Return its rendered height. The parent accumulates heights for flow layout.
  4. Emit hit rects for all interactive regions, positioned in content space.
  5. Use theme tokens for all colors. No hardcoded color values.
  6. Scale all dimensions by scale_factor. No pixel values without scaling.
  7. Read state from widget_states/datatable_states/etc. for persisted state. Fall back to props for initial values.
  8. Support inline text formatting if the widget renders user-facing text.

A widget renderer must NOT: - Access the network, filesystem, or any state outside the passed parameters. - Modify other widgets' state. - Generate events (events are generated by the main event loop, not by renderers). - Paint outside its allocated region. - Assume a specific panel size or scroll position.


7. The AMP Event Surface

7.1 Universal Event Shape

All widget events sent to the process have this structure:

---
command: ui.event
source: <panel_id>
from: display
widget: <widget_id>
action: <action_name>
[action-specific headers]
---
[optional payload body]

The command is ui.event (per 05_amp-display-protocol.md §3.4). The source header identifies the panel, action identifies what happened. The from header is the AMP identity of the display service.

7.2 Standard Actions

Action Meaning Key headers
select Item(s) selected item or items (comma-joined)
activate Item double-clicked / Enter pressed item
sort Column header clicked column, order (asc/desc)
submit Text input Enter pressed value
change Toggle/checkbox/slider changed checked or value
page Pagination button clicked page (1-indexed)
expand VirtualList tree node expanded/collapsed node, expanded (bool)
tab Tab switched tab (index)
accordion Accordion section toggled section (index)
navigate Breadcrumb segment clicked path or action param
focus Widget received focus widget
blur Widget lost focus widget

7.3 Event Consistency Rules

  • Every event includes widget (the widget ID) and source (the panel ID).
  • Item identifiers use the id field from the JSON data pushed via ui.data.
  • Multi-select events use items (comma-joined IDs), not repeated headers.
  • Boolean values are true/false strings, not 1/0.

8. Composition Patterns

8.1 Panel as Application

A typical AMP application is a single panel with a menu bar, a content area with widgets, and a status bar. The process creates the panel, pushes data, and handles events.

+- Menu Bar ----------------------+
| File  Edit  View  Help         |
+--------------------------------+
|                                |
|  Widget content area           |
|  (scrollable)                  |
|                                |
+--------------------------------+
| Status: 42 items | 3 selected  |
+--------------------------------+

Example: minimal panel

emit "display" ui.panel id="my-app" title="My App" width=600 height=400 body=<<MD
# Hello World

This is a minimal AMP application.

[Click me](app.clicked)
MD

8.2 Master-Detail

The canonical pattern for apps where selecting an item shows its detail. Implemented as a SplitPane with a list widget on the left and detail content on the right.

+- Menu Bar ----------------------+
| File  Edit  View               |
+--------+-----------------------+
| Inbox  |  Subject: Meeting     |
| Sent   |  From: alice@...      |
|>Drafts |  Date: 2026-04-18     |
| Trash  |                       |
|        |  Body text here...    |
+--------+-----------------------+
| 3 drafts                       |
+--------------------------------+

The process handles select events from the list widget and pushes updated content to the detail area via ui.data or by re-sending ui.panel with new body content.

When to use: Email clients, contact managers, settings panels, any app where browsing a list and viewing an item's detail is the primary interaction.

8.3 Dual-Pane (Side-by-Side)

Two peer widgets showing related but independent content. The user works with both simultaneously. Implemented as a SplitPane with equal or adjustable ratio.

+- Menu Bar ----------------------+
| File  Edit  Tools              |
+---------------+----------------+
| /home/user/   | /mnt/backup/   |
| Documents/    | Documents/     |
| README.md     | README.md      |
| report.pdf    | old-report.pdf |
+---------------+----------------+
| [Copy] [Move] [Delete]        |
+--------------------------------+
| 2 selected                     |
+--------------------------------+

The process tracks which pane is "active" (source) based on which DataTable last received a select event. Toolbar actions operate on the active pane's selection with the other pane as destination.

When to use: File managers, diff viewers, comparison tools.

8.4 Three-Pane (Sidebar + Main + Inspector)

Nested SplitPanes: outer split for sidebar vs main area, inner split for main vs inspector. The sidebar uses a VirtualList (tree or flat) for navigation, the main area shows content, the inspector shows metadata.

+--------+------------------+-------+
| Nav    | Main content     | Props |
|        |                  |       |
| > Mail | [items here]     | Size  |
|   Cal  |                  | Date  |
| > Files|                  | Type  |
+--------+------------------+-------+

When to use: IDE-like tools, asset managers, admin panels. Use sparingly — three panes require a wide display and add cognitive load.

8.5 Settings / Preferences

A Tabs widget with each tab containing a form layout of inputs (TextInput, Toggle, Dropdown, Slider). Each tab is a category; switching tabs shows different settings.

+- Preferences ------------------+
| [General] [Display] [Network] |
+--------------------------------+
| Font size:  [14    ]           |
| Theme:      [Dark  v]         |
| Dense mode: [x]               |
| Animations: [====o---]        |
+--------------------------------+
| [Apply] [Cancel]               |
+--------------------------------+

The process collects values on Apply (reading each widget's state from the events that fired during editing) and persists them.

When to use: Any app with user-configurable behavior.

8.6 Long-Running Operation with Progress

The process starts the operation, creates or updates a panel with a ProgressBar widget, and periodically sends ui.set commands to update the progress value.

+- Copying Files ----------------+
| Copying 3 of 47 files...      |
| [===========              ] 23%|
|                                |
| Current: large-video.mp4      |
|                                |
| [Cancel]                       |
+--------------------------------+

For non-blocking operations, the progress panel can be a Toast or a status bar update on the originating panel rather than a separate window.

When to use: File copy/move, indexing, sync operations, any operation that takes more than ~2 seconds.

8.7 Wizard (Multi-Step Flow)

A single panel whose body content changes at each step. The process tracks the current step and re-sends ui.panel with new body content on each Next/Back action. A Breadcrumb widget at the top shows progress.

+- Setup Wizard ------------------+
| Step 1 > Step 2 > [Step 3]     |
+---------------------------------+
|                                 |
| Choose your sync folder:        |
| [~/Documents         ]          |
|                                 |
| [< Back]           [Next >]    |
+---------------------------------+

When to use: Initial setup, onboarding, any process with ordered steps. Keep to 3-5 steps maximum.

8.8 Modal Dialogs

A modal dialog is a panel with layer: overlay. The display service renders it above normal-layer panels. The process is responsible for creating and removing it — there is no framework-managed modal lifecycle.

Confirmation pattern: 1. Process creates modal panel with prompt text + OK/Cancel buttons 2. User clicks OK -> event fires -> process acts and removes modal 3. User clicks Cancel -> event fires -> process removes modal

8.9 Ephemeral Notifications

A toast is a panel with layer: notification and ttl: <milliseconds>. The display service auto-removes it after the TTL expires. No process action required for removal.


9. The Mesh Dimension

9.1 Remote Panels

Because panels are declared via AMP messages, a process on one mesh node can create a panel on another node's display service. The addressing scheme (app.node.amp) makes this transparent — the hub routes the message to the correct display service.

This means: - A monitoring daemon on mmc can create status panels on cachyos - A Mix script on cachyos can create a file browser panel on mko - A mobile device (future) can see panels from any mesh node

9.2 Panel Ownership Across the Mesh

The source process identity is preserved in the panel's from header. Events route back to the originating process via the hub regardless of which node's display service is rendering the panel. For federated (cross-mesh) panels, the source_peer header (05_amp-display-protocol.md §15.2) identifies the originating mesh — distinct from from, which identifies the process within that mesh.

9.3 Degradation (Future Direction)

The protocol is designed so that the same ui.panel message could be rendered by different display services at different fidelity levels. This is a design intent, not a current capability — only cosmix-deskd exists today.

Display service Fidelity Status
cosmix-deskd Full Active
Web renderer High Future
Terminal renderer Medium Future
Plain text (email, log) Low Inherent (markdown is readable)

Apps should not depend on renderer-specific capabilities. The lowest-fidelity degradation (readable markdown in an email) is inherent in the protocol format and requires no additional implementation.


10. The Compositor Integration (Future)

When cosmix-comp replaces cosmic-comp, the display model extends downward:

10.1 Surface Mapping

Each deskd panel becomes a Wayland surface. The compositor manages surface compositing; the shell (cosmix-shell) manages placement policy.

Display concept Compositor concept
Panel xdg_toplevel surface
Modal panel xdg_popup or layer-shell overlay
Toast layer-shell notification
Panel position Shell-assigned geometry
Panel layer Surface stacking order

10.2 Hot Path Separation

The compositor boundary uses postcard binary framing (not AMP) for performance-critical paths:

  • Pointer motion (60-165 events/sec)
  • Frame callbacks (tied to display refresh)
  • Surface damage (per-frame rendering)

AMP stays for orchestration: - Panel creation/removal - Workspace switching - Application-level commands

This separation is load-bearing. AMP's markdown frontmatter overhead (~200 bytes of headers per message) is fine for UI events at 10/sec but wasteful for pointer motion at 165/sec.

10.3 Shell as Window Manager

cosmix-shell is a process that speaks both postcard (to compositor) and AMP (to apps). It receives surface-mapped events from the compositor and decides placement. It receives panel-creation requests from apps and tells the compositor where to put them.

The shell IS the window manager. There is no separate WM process.


11. State Management

11.1 State Categories

Category Owned by Persists across Examples
Application state Process Process lifetime Data models, user preferences
Widget state Display service Panel lifetime Split ratio, column widths, sort order, scroll position, selection
Rendering state Display service Single frame Hit rects, pixmap, layout geometry

11.2 What Survives Panel Close and Reopen

Nothing. When a panel is removed and recreated, the display service starts fresh. The process is responsible for restoring any state it cares about by sending the appropriate ui.panel, ui.data, and ui.style commands.

Widget states (split ratios, column widths, sort orders) are lost on panel removal. If these need to persist, the process must save them (e.g., in a config file) and restore them via widget properties on recreation.

11.3 Error and Loading States

Error and loading states are app-level concerns, not widget-level. The process decides what to show when data is unavailable or an operation fails.

Standard patterns: - Loading: Show a Progress widget in indeterminate mode (omit value) while data is being fetched. Replace with content via ui.data when ready. - Empty: Show a placeholder message in the content area. Use text-dim color and centered layout. - Error: Show an error message with error color token. Offer a retry action if applicable.

These are not built-in widget modes — they are compositions the process creates using existing widgets.


12. Accessibility (Direction)

Accessibility is a first-class concern. The current implementation has gaps (noted below), but the design model should not introduce barriers that are expensive to reverse.

Current state: - Keyboard navigation: partial (Tab cycles focusable widgets, menu shortcuts work) - Focus ring: visible on focusable widgets - Color contrast: dark theme tokens chosen for WCAG AA contrast - Screen reader support: not implemented - High contrast mode: not implemented - Reduced motion: not implemented (smooth scroll is the only animation)

Direction: - All interactive widgets must be keyboard-accessible. This is why Section 3.2 extends focus to DataTable and VirtualList, not just text inputs. - AT-SPI integration (Linux accessibility protocol) is a future workstream. The gadget model (Section 2.3) maps naturally to accessible objects — each HitRect can carry role, name, and state metadata. The HitRect list maintained for input hit-testing is the natural source for the AT-SPI semantic tree: during each render pass, the same data structure that determines "what did the user click" can be exported as "what can the screen reader walk." This means accessibility doesn't require a separate shadow DOM — the hit-test infrastructure already provides the structural information AT-SPI needs. - High contrast mode will be a theme variant, not a separate rendering path. - Text scaling will use the existing typography system (Section 5.2) — all sizes derive from theme.font_size, so increasing the base size scales everything.

Rule for widget authors: Do not create widgets that are mouse-only. Every widget interaction must have a keyboard equivalent, even if the keyboard path is less convenient.


13. Internationalization (Direction)

The display model must support international text without special-casing.

Current state: - Unicode text rendering: supported (cosmic-text handles complex scripts) - RTL languages: not tested, likely partially working via cosmic-text's bidi support - IME (Input Method Editor): not implemented (required for CJK input) - Locale-aware formatting: not implemented (dates, numbers use fixed formats)

Direction: - cosmic-text's bidi and shaping support is the foundation. RTL layout needs testing and likely minor fixes in widget renderers (text alignment, padding direction). - IME integration requires compositor-level support (Wayland text-input protocol) and display-service-level compositing of pre-edit text. Deferred until cosmix-comp. - Locale-aware formatting will be added to DataTable cell formatters (date, number, currency) when needed.

Rule for widget authors: Do not assume LTR text direction. Use logical (start/end) rather than physical (left/right) alignment where possible.


14. Design Discipline

14.1 Adding a New Widget

Before adding a widget to the display service:

  1. Check the registry. Is there an existing widget that can serve this need with different properties or data? Prefer reuse over addition.
  2. Write the declaration syntax. How is this widget declared in markdown? What properties does it accept? What content format does it expect?
  3. Define the events. What interactions does this widget generate? Use standard action names from Section 7.2 where possible.
  4. Define the keyboard behavior. How does this widget behave when focused? What keys does it handle? (Section 3.2)
  5. Design the visuals using the tokens and states from Section 5. No new colors, no new highlight patterns.
  6. Implement the renderer following the contract in Section 6.4.
  7. Update the protocol spec (widget registry, event payloads, header reference).

14.2 Auditing an Existing Widget

When reviewing a widget against this spec:

  • Does it use theme tokens or hardcoded colors?
  • Does it scale all dimensions by scale_factor?
  • Are its hover/selection/focus states consistent with Section 5.5?
  • Does it emit events in the universal shape from Section 7.1?
  • Does it follow the rendering contract from Section 6.4?
  • Is it keyboard-accessible? (Section 12)
  • Does it support inline text formatting? (Section 5.2)

14.3 What This Spec Does NOT Cover

  • API documentation — function signatures, parameter types, return values. That's code-level documentation, not design specification.
  • Implementation details — pixmap allocation strategies, text shaping internals, layout engine configuration. These are implementation choices that may change without affecting the design model.
  • Application design — how to structure a file manager, how to build a mail client. Section 8's composition patterns are structural guidance, not app design specs.