Cosmix Specification Suite
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.panelmessage, 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 →
selecton widgetfiles - 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.
AMP — Cosmix Wire Protocol Specification
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
AmpMessagestruct 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:
- Machines — deterministic header parsing for routing, dispatch, and filtering. A router reads
to:,from:,command:and forwards. No understanding required. - Humans —
cat,grep, render in any markdown viewer. A developer debugging at 2am reads the message and knows what happened. - AI agents — natural language comprehension without schema definitions. An agent reads the full message — headers and body — and reasons about it as text.
This is not a nice-to-have. It is the core constraint that drives every format decision in 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 commands —
command: search,args: {"query": "invoices"},from: cosmix-mail.cosmix.mko.amptells 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
.ampis the mesh TLD- Node names (
cachyos,mko,mmc) are subdomains under.amp cosmixis the application namespace (alwayscosmixfor 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:
- Read until
---\n→ start of a new message - Read lines until the next
---\n→ these are headers - Enter body mode. Read lines until
---\nappears: a. Peek at the next line — if it isEOM, yield the complete message b. Otherwise, the---is body content (e.g., a markdown horizontal rule); continue reading body - After yielding, return to step 1
Empty message (heartbeat/ACK) on the wire:
---\n---\n---\nEOM\n
That is: opening ---, empty header block closed by ---, empty body closed
by ---\nEOM\n. Three --- lines plus EOM.
Single-message context. When parsing a single message from a file or a
WebSocket text frame, the trailing ---\nEOM\n may be absent. The parser
treats EOF as an implicit end-of-message. The ---\nEOM\n terminator is
required only on persistent streams where multiple messages are concatenated.
Resync on error: If the parser loses state (partial read, corrupted bytes),
scan forward for \nEOM\n and resume at the next ---\n after it. This is
impossible with length-prefixed framing — a corrupted length field means every
subsequent message is misaligned.
5.3 The Four Shapes
One format, one parser, four message shapes:
| Shape | Description | Use case |
|---|---|---|
| Full message | Headers + markdown/text body | Events, rich responses, errors with context |
| Command | Headers only (including args:), no body |
Requests, acks, simple responses |
| Data | Minimal json: header, no body needed |
High-throughput streams, structured payloads |
| Empty | No headers, no body (---\n---\n) |
Heartbeat, ACK, NOP, stream separator |
All four are delimited by --- and parsed identically.
5.4 Format Examples
Shape 1 — Full message (headers + body):
---
amp: 1
type: event
id: 0192b3a4-7c8d-0123-4567-890abcdef012
from: maild.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 |
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:
- Browser sends AMP request:
command: webrtc-offerwith SDP in body - Server responds: SDP answer + ICE candidates in AMP response body
- WebRTC data channel opens — binary streams flow directly
- 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)
AMP Standard Command Vocabulary
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, notgetContent - 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.listis correct;list-accountsis 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:
HELPis the discovery entry point. Scripts callHELPfirst to learn what a service can do. The response is a JSON array of command descriptors.INFOprovides metadata for fleet inventory and version tracking.QUITrequests 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,CLOSEfrom 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:
- Name it correctly.
service.verb-nounform, standard verbs first. - Document it in HELP. Every command must appear in the service's
HELPresponse with a description and expected args. - Use standard return codes. Don't invent new codes; the 0/5/10/20 range is sufficient.
- Args are JSON objects. Use
args:header for command parameters, not positional encoding. - Bodies are for content. Structured data goes in
args:orjson:. Bodies are for markdown, prose, or large payloads. - Don't duplicate display commands. If your service needs widget
introspection, those commands are in
05_amp-display-protocol.md§3.11. Don't reinventui.listorui.get.
Document created: 2026-03-29, rewritten 2026-04-18 Supersedes: AMP Command Vocabulary v0.1 (partial command registry)
AMP Topic Pub/Sub
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.panelmessage includes thesubscribeheader, 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 explicittopic.subscribe/topic.unsubscribecalls.On panel creation (first
ui.panelwith a givenid): - Ifsubscribeis present and non-empty, the display service issuestopic.subscribe name=<value>to the hub. - Ifsubscribeis present and empty, it is treated as absent — no subscription. - Ifsubscribeis absent, no subscription is created.On panel update (subsequent
ui.panelwith an existingid): - Ifsubscribeis absent, the existing binding (if any) is preserved. This matches the generalui.panelupdate rule that absent headers preserve stored values. - Ifsubscribeis present and equals the current binding, no action. - Ifsubscribeis present and differs from the current binding (including transition from unbound to bound), the display service MUST atomically: issuetopic.unsubscribefor the old binding (if any), issuetopic.subscribefor 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. - Ifsubscribeis present and empty, the existing binding (if any) is removed: issuetopic.unsubscribe, clear the stored binding. This is theui.panel"empty string clears header" rule applied to subscriptions.On panel removal (
ui.remove, orphan timeout, or TTL expiry), the display service MUST issuetopic.unsubscribefor any active binding before removing the panel.The
subscribeheader is purely sugar over thetopic.subscribe/topic.unsubscribecommands 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
Tdisconnects, the hub marks the snapshot forTas stale. Subsequent subscribers receive the stale snapshot with atopic_stale: trueheader on the first delivery (see § 3.11).- If the same peer reconnects (identified by registered service name) and publishes to
Tbefore 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 thesubscribeheader onui.panel(§ 3.1.2), which a renderer MAY implement at any conformance level. A renderer that does not implement thesubscribeheader 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 supporttopic.*MUST respond totopic.*commands with RC 10 and the error body{"error": "topic_not_supported"}. Processes detect broker availability by reading theextensionsfield of thehub.pingresponse (§ 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 seesextensions.topicabsent from the ping response and SHOULD NOT issuetopic.*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:
coreis 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.
topicis the first such extension; future extensions (stream,presence, etc.) will appear alongside it. - Versions are independent per extension. A hub MAY support
core: 1.0andtopic: 1.1simultaneously. 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
extensionsMAY 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:
- Human tailing the topic (
mix sub sysmon.metrics) sees the inner AMP message in its plain-text form and cangrep/ read it directly. - Display service parses the inner message's
commandheader and dispatches through its existing widget rendering path. - 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=trueedge 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
subscribeheader onui.panelbinds an entire panel to one topic. Future versions may add a widget-levelsubscribeattribute 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.tomlmapping 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_producerlock 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.
Mix Language Reference
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), recursiveglob/walk/path_parts(Phase 4), terminator unification —endeverywhere (Phase 5), and JSONL helpersread_lines/read_json/read_jsonl(Phase 6). Deferred to v0.3: destructuring assignment, lexical closures converging named-and-anonymous semantics, catchableexit, two-registry HOF unification.
Quick gotcha list
Top-of-doc shortlist. If you only read this section, you'll avoid the common traps:
- Concatenation is
..(not||).||is the statement-chain operator (run-if-failure), same as shell. String concat in expressions uses... - Command-line args are positional:
$1,$2, ... (ARexx/shell style). There is no upper bound —$1through$Nfor as many args as the script was invoked with.args()returns the same args as a list (use it when you needlength()or iteration). onblocks useon event.name(unquoted) and close withend. Example:on dbview.page ... end.onis statement-position only — it registers a global handler and is not legal insidefunctionbodies.send/emit/addressare ordinary statements — they work at script top level, insideonhandlers, and insidefunctionbodies. 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.)- String interpolation does NOT recurse. If
$var = "hello"and$template = "${var}", then"${template}"produces the literal string${var}, nothello. This matters when passing complex strings through heredocs. - Heredoc interpolation expands
${var}eagerly — one pass only. $var.fielddot 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 theValue::Map*fallback. Mid-chain non-map values yieldnil. (Shipped in Phase 0 of v0.2.0; earlier v0.1.0 revisions only honoured the first dot.)- Comparison operators have asymmetric coercion.
==/!=cross-type-coerce string↔number via parse-as-f64 (value.rs:79–96), so"5" == 5istrue. But</>/<=/>=route throughnum_cmp(evaluator.rs:2022) which errors on any value that doesn't parse as a number — includingnil. Sonil > -1is a runtime error, notfalse.eq/neare strict string comparisons with no coercion. - Keywords
and/orfor expressions,&&/||for statement chaining. They are NOT interchangeable. - All blocks close with
end(since v0.2.2). Legacydone/nextstill parse with deprecation warnings — removal planned for v0.3. See table below. - Event fields live in
$event["headers"], not top-level$event.$eventhas only three keys:command,headers,body. Value::Mapfield access falls back to the*key.$cfg.hostreturns 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. Returnnilwhen not provided.$_— result of pipe LHS in pipe expressions$event— only available insideonhandlers, read-only, map withcommand/headers/bodykeys$rc— return code from most recent AMPsendstatement
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— runotheronly if$rc == 0afterstmtstmt || other— runotheronly if$rc != 0afterstmtstmt | external_cmd— pipestmtstdout 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, notend. $eventis 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"]isnil. 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:
onIS statement-position only — it registers a global event handler and is not legal inside afunctionbody. 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 currentValue::Map, falling back to the*key if present (evaluator.rsExpr::InterpolatedStringarm). A non-map intermediate value yieldsnilfor 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
localscope (all vars in current function frame). endcloses every block (since v0.2.2). Legacydone/nextstill parse with deprecation warnings; removal planned for v0.3.
From shell
$varin expressions is OK;${var}only works inside strings.- No glob expansion:
*.txtis literal. Useglob("*.txt"). - No word splitting:
"a b c"is one string, not three. Usesplit(x, " "). &&/||/|are statement-level only.- Errors don't abort the script — use
try/catchordie.
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
- 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. - Comparison asymmetry:
==cross-type-coerces (so"5" == 5istrue), but</>error on coerce failure (sonil > -1raises a runtime error rather than returningfalse). This is value.rs:79 vs evaluator.rs:2022 — two code paths, two rules. If you might comparenil, guard with??or an explicitif $x == nil. - Mid-chain non-map in interpolation yields nil:
${cfg.host.foo}returns nil if$cfg.hostis a string, not an error. Same rule as expression-position field access. The chain walks as far as it can onValue::Map, then short-circuits tonil. Value::Mapfalls back to*:$cfg.unknown_keyreturns the value of$cfg.*if set. Convenient as a defaults pattern, surprising if you forgot you set*.exit(N)is not catchable:builtin_exit(builtins.rs:745) callsstd::process::exitdirectly. It bypassestry/catch, skips Rust-side cleanup, and kills the host process if Mix is embedded. Usedie "msg"(catchable, raisesMixError::DieError) when you want intercept-able termination. v0.3 will convertexitto a catchable error variant.- 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. Anonymousfunction(...)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. args()vs$N: both work, but$1is idiomatic for command scripts.args()is better when you needlength()or iteration.
See also
src/crates/cosmix-lib-mix/src/parser.rs— authoritative grammarsrc/crates/cosmix-lib-mix/src/builtins.rs— all 115 builtins (future Ch 04a)src/apps/sshm.mix— complete example of a Mix GUI appsrc/apps/lib/ui.mix— library ofpanel/data/statushelpersmix/dbview— SQLite viewer using the datatable featuressrc/_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)
AMP Display Protocol Specification
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
-
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). -
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.
-
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.
-
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.
-
Graceful degradation. A
ui.panelmessage 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. -
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. -
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
idheader ofui.panel. The process chooses semantic, stable names (mail-sidebar,file-browser,compose). - Widget IDs: assigned by the process in the
idproperty of code block declarations (~~~textinput id=to). - Data item IDs: assigned by the process (or its upstream data service) in the
idfield of JSON data objects. For example,maildgenerates email IDs; the process passes them through inui.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: valuepairs, 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.panelmessages, the body is GFM markdown. - For
ui.datamessages, the body is JSON. - For
ui.styleandui.thememessages, the body iskey: valuepairs. - Body termination and framing follow the Chapter 01 v0.5 wire format. Messages are terminated by the
---\nEOM\nend-of-message marker. Markdown horizontal rules (---) in the body are unambiguous because the parser requires both---\nandEOM\nin 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. See01_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\ntwo-line marker (Ch01 v0.5). - Markdown horizontal rules (
---) inui.panelbodies are unambiguous. - On WebSocket transports, one text frame = one complete AMP message.
- On SMTP transports, the MIME boundary delimits the
text/x-amp-panelpart.
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_idis message identity (request/response correlation, deduplication, logging).idonui.panelis panel identity (targeting, hierarchy, lifecycle). These are distinct concepts and MUST NOT be conflated. A singleui.panelmessage may have both:msg_idfor transport andidfor 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 thetargetorsourceheader. Mix scripts address widgets viaui.eventheaders, 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 bycosmix-deskdin Phase A. In Phase B, these concerns migrate to a dedicatedcosmix-shellcommand 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
---
-  **{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:
hub.list→ get registered servicesmenu.list→ discover menu items and their IDsui.list→ discover interactive widgets and their current stateui.get→ read specific widget values before acting- 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). |
 |
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:
- Extract the first word of the language hint as the widget type name.
- Look up the name in the widget type registry (Section 6). If not found, render as a plain code block.
- Parse remaining words as
key=valueproperties. Quoted values (key="multi word") preserve spaces. - 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:
~~~mixblocks MUST NOT be rendered as visible content.- The display service MUST extract
~~~mixblocks and forward them to the script execution service (Section 7). - Multiple
~~~mixblocks in a single panel body are concatenated in order. ~~~mixblocks in federated panels (source_peeris set) MUST be stripped silently.
4.5 Links as Actions
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:
 — Lucide icon by name
 — Remote image
 — 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.
-  **{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:
- Display service receives
ui.panelmessage. - Display service parses the markdown body, encounters
~~~mixblock. - 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.scriptmessage 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. - The script service creates a Mix evaluator, injects
$PANEL_IDas a context variable, and executes the script. - The script's
onhandlers register event subscriptions with the hub. - The remaining markdown (without the
~~~mixblock) 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:
- Display service receives
ui.panel, notesscriptheader. - Display service forwards the script path to the script execution service.
- Script service loads the file, creates a Mix evaluator, injects
$PANEL_ID, and executes. - 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
-  Documents
-  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:
onsends aui.subscribemessage to the hub with the specified filter criteria.- The hub routes matching
ui.eventmessages to the script's process. - When a matching event arrives, the Mix evaluator executes the handler body.
- The
$eventvariable is implicitly available inside the handler, containing all event payload fields as a Mix object. - Multiple
onhandlers for the same source are allowed. All matching handlers execute in registration order. - 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
sendresponse) holds the event loop — this is intentional and prevents reentrancy bugs. Long-running handlers SHOULD useemit(fire-and-forget) instead ofsend(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="-  **{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 |
mailto:address |
Compose plain email |
8.2 AMP Command Dispatch
When a link with an AMP command URI is clicked:
- The display service MUST emit a
ui.eventwithactionset to the full URI string. - The hub routes the event to subscribed processes.
- 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
---
>  **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:
-  **{name}** ({unread})
Given a data item {"icon": "inbox", "name": "Inbox", "unread": 3}, this produces:
-  **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.templateand persist until replaced. - If no template is set, the renderer SHOULD display each item's
idas 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\nstream terminator for readability. On the wire, every message ends with---\nEOM\nper01_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 |
>  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=">  **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="-  **{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=" `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 |
>  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.
Cosmix Display Model
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, andonkeywords. 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.setwith afocus: trueproperty 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.dropevent 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:
rulecolor 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):
- Panel background
- Menu bar (fixed at top, not scrolled)
- Widget content (scrolled)
- Scrollbar track and thumb
- Focus ring
- Dropdown menus
- Context menus
- 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:
- Accept the standard parameter set — pixmap, text renderer, theme, position, scale, hit rects, and all state maps.
- Render within its allocated width (
max_width). May use less but must not exceed. - Return its rendered height. The parent accumulates heights for flow layout.
- Emit hit rects for all interactive regions, positioned in content space.
- Use theme tokens for all colors. No hardcoded color values.
- Scale all dimensions by
scale_factor. No pixel values without scaling. - Read state from widget_states/datatable_states/etc. for persisted state. Fall back to props for initial values.
- 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) andsource(the panel ID). - Item identifiers use the
idfield from the JSON data pushed viaui.data. - Multi-select events use
items(comma-joined IDs), not repeated headers. - Boolean values are
true/falsestrings, not1/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:
- Check the registry. Is there an existing widget that can serve this need with different properties or data? Prefer reuse over addition.
- Write the declaration syntax. How is this widget declared in markdown? What properties does it accept? What content format does it expect?
- Define the events. What interactions does this widget generate? Use standard action names from Section 7.2 where possible.
- Define the keyboard behavior. How does this widget behave when focused? What keys does it handle? (Section 3.2)
- Design the visuals using the tokens and states from Section 5. No new colors, no new highlight patterns.
- Implement the renderer following the contract in Section 6.4.
- 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.