Alabama Wire Protocol

Working Draft,

This version:
https://docs.aph.gdn/spec/
Issue Tracking:
GitHub
Editor:
The Aleph Project


Abstract

Alabama is the wire protocol spoken between Aleph satellites and the aleph server runtime. A satellite holds one persistent, encrypted, bidirectional connection to the server and exchanges length-prefixed binary protobuf frames carrying raw device input, rendered output, streaming media, tool calls, and control. This specification defines the framing, the transport and admission model, the handshake and capability surface, the operations catalogue, and the versioning discipline that lets a fleet of heterogeneous, independently-deployed devices interoperate with one server.

1. Introduction

Alabama is the satellite-to-core wire. A satellite is any device or process that captures input for, or renders output from, the Aleph assistant: a microphone-and-speaker board, a phone, a desktop app, a chat bridge. The core is the aleph server: it routes audio, runs the conversation, and owns settings and identity. Everything a satellite needs to do — stream a microphone, play speech, show an image, expose a device action as a tool, emit a button press — happens over this one connection.

Aleph ships many satellites itself, but this wire is specified, not left an internal detail, so that anyone can build a satellite — first- or third-party —​that plugs into the same feature set.

The guiding principle is that a satellite sends raw input and renders raw output; it never makes a core decision. It does not resolve who is speaking, does not decide whether an utterance is a command, does not interpret an event. It streams bytes and declares what it can do. The intelligence lives in the core.

1.1. Design principles

Raw in, rendered out

A satellite reports observations (audio, text, events) and performs instructions (render, play, invoke). Interpretation is the core’s job.

The wire is the one compatibility surface

The rest of the system is rebuilt freely; the wire is held to additive, non-breaking discipline, because satellites are not in the server’s deploy unit — firmware does not ship in lockstep with the server.

Fail loud, recover automatically

Failures surface loudly in logs and on the wire; where recovery is possible it is automatic, never a user chore. Where it is not — a device too old for the framing floor — the protocol fails legibly and points at the fix.

1.2. Document conventions

Wire schema in this document is **generated from the canonical .proto source** under docs/protocol/proto/; the proto blocks below are included at build time, never hand-copied, so the schema and the prose cannot drift. Sequence and state diagrams are authored in Mermaid and rendered in the browser.

2. Conformance

MUST, MUST NOT, SHOULD, MAY and their kin carry their BCP 14 [RFC2119] [RFC8174] meaning where they appear in all capitals.

A satellite implements every Tier 0 and Tier 1 operation, plus each Tier 2 operation gated by a capability, tool, event, or setting it advertises. A core implements every operation it may exchange with an admitted satellite.

2.1. Tiers

Operations are partitioned into three tiers, by how universal they are and how they are gated (see § 13 Version gate and compatibility for how this stays compatible across versions).

Tier 0 — universal, mandatory

Hello, HelloResponse, Error, Ping, PingResponse, Paired. Without these a peer is not a satellite. wire_version anchors this universal baseline.

Tier 1 — stream substrate

StreamChunk, StreamEnd, StreamCancel. Generic and shared; every streaming capability draws on it.

Tier 2 — capability-gated

Every other operation, each behind the capability, tool, event, or setting that declares it. A satellite that does not advertise a capability never receives its operation.

Tier 0 and Tier 1 are an application-agnostic substrate — framing, transport, identity, streams, and liveness, which could carry any typed device protocol —​and Tier 2 is the aleph voice-assistant semantics layered on it (§ 14 Prior art and alternatives). Keeping the substrate and the voice layer in one protocol keeps the Envelope oneof flat and fully typed; the substrate could be lifted out if a second application needed it.

2.2. Extensibility

The wire is append-only, in every tier. A new operation is a new oneof arm (plus, in Tier 2, the capability value that gates it); a new option is a new field or enum value. A field number, once assigned, is never reused or repurposed — not even with the same type. Implementations MUST ignore fields they do not recognise and tolerate fields that are absent (protobuf’s native behaviour: an unknown field’s wire type carries its own length so a parser can skip it, [PROTOBUF]). Two peers at different versions therefore always interoperate on their common subset: a newer peer’s additions are ignored by an older one, and an older peer’s smaller surface is handled by the newer one. This holds for Tier 0 and Tier 1 as much as Tier 2 — the substrate only grows, it never breaks (see § 13 Version gate and compatibility).

Because the wire is append-only and every Tier 2 operation is gated, a capability can be specified before any device implements it — an unexercised arm costs nothing. Several here are specified but not yet built: DEFERRED store-and-forward (CaptureAck, capture_ts), observe-copies (TOOL_ACTIVITY), and Presence. Each ships when a concrete satellite needs it.

3. Architecture overview

3.1. Roles

The runtime is two kinds of process: satellites (many, heterogeneous, independently deployed) and the core (one server). Alabama is spoken only between satellites and the core; everything else the core does is internal to it and never on this wire.

3.2. The two identities

Alabama carries two distinct credentials:

A device is not a speaker: a shared living-room board is one device with many speakers; a personal phone is one device bound to one user.

3.3. Connection lifecycle

sequenceDiagram
    autonumber
    participant Sat as Satellite
    participant Core
    Sat->>Core: transport connect
    rect rgb(238,238,238)
    note over Sat,Core: encrypted handshake — device key authenticated
    end
    note over Core: device_id = fingerprint(pubkey), admission lookup
    Sat->>Core: Hello {capabilities, tools, events, settings}
    Core->>Sat: HelloResponse {Accepted}
    note over Sat,Core: surface active — operations flow both ways
    Sat-->>Core: CaptureOpen + StreamChunk… (input)
    Core-->>Sat: MediaRenderOpen + StreamChunk… (output)

4. Protocol by example

Before the per-operation reference (§ 10 Operations), here is the protocol in motion. Each line below is one frame: its direction, the operation, and the Envelope it carries. +audio / +tts marks a raw payload tail — present only for streaming StreamChunks; discrete content such as [ image/png ] rides inline in the Envelope itself (a protobuf bytes field, no tail). Frame and id rules are in § 6 Wire encoding and framing — recall that a satellite mints odd ids and the core even ones.

4.1. A voice exchange, end to end

A living-room board wakes on its wakeword and streams microphone audio up; the core transcribes it, decides, speaks a reply, and closes the exchange with a terminal on the CaptureOpen id.

sequenceDiagram
    autonumber
    participant Sat as Satellite
    participant Core
    note over Sat,Core: wakeword fires
    Sat->>Core: CaptureOpen (id 1, trigger WAKEWORD)
    Sat->>Core: StreamChunk (seq 0) + audio
    Sat->>Core: StreamChunk (seq 1) + audio
    Sat->>Core: StreamChunk (seq 2) + audio
    note over Core: endpoint detector hears end of speech
    Core->>Sat: StreamEnd (END_DETECTED)
    note over Sat: mic stops capturing
    note over Core: transcribe, decide, synthesize
    Core->>Sat: MediaRenderOpen (id 2)
    Core->>Sat: StreamChunk (seq 0) + tts
    Core->>Sat: StreamChunk (seq 1) + tts
    Sat->>Core: PlaybackDone (id 2)
    Core->>Sat: Response (id 1) — exchange complete

On the wire, frame by frame:

Sat → Core   CaptureOpen      {id 1, AUDIO, trigger WAKEWORD, mode LIVE}
Sat → Core   StreamChunk      {id 1, seq 0}   +audio
Sat → Core   StreamChunk      {id 1, seq 1}   +audioCore → Sat   StreamEnd        {id 1, END_DETECTED}              ← core's endpoint detector closes the mic
   (core transcribes → decides → synthesizes)
Core → Sat   MediaRenderOpen  {id 2, SPEAKER}
Core → Sat   StreamChunk      {id 2, seq 0}   +ttsCore → Sat   StreamEnd        {id 2, COMPLETE}
Sat → Core   PlaybackDone     {id 2, frames 9600}               ← render terminal
Core → Sat   Response         {id 1}                            ← terminal on the CaptureOpen id (empty)

What to notice:

4.2. A tool call mid-exchange

If the core needs something only a device can do mid-exchange — read a screen, snap a photo, toggle a local control — it invokes a satellite-hosted tool and waits for the result before continuing, all under the same CaptureOpen id, which stays open:

   (… mic stream closed, as above …)
Core → Sat   Invoke       {id 4, name "screen.capture"}
Sat → Core   ToolResult   {id 4, content [ image/png ]}
   (core continues → MediaRenderOpen → … → Response {id 1})

Invoke is core-initiated, so its id 4 is even; the satellite replies with a ToolResult on the same id, and the exchange’s id 1 is untouched. The target need not be the satellite hosting the input — the core invokes a tool on whichever satellite hosts it, so a voice exchange on the living-room board can pull a screenshot from the user’s desktop.

4.3. Barge-in

The user talks over the assistant. The satellite opens a new capture, and the core cancels the TTS stream still in flight:

   (core mid-render: MediaRenderOpen id 2 — TTS playing)
Sat → Core   CaptureOpen    {id 3, AUDIO, trigger WAKEWORD}   ← user cuts in
Core → Sat   StreamCancel   {id 2, BARGE_IN}                  ← core drops the TTS render
Sat → Core   PlaybackDone   {id 2, frames 3200}               ← render ends early
   (the new exchange id 3 proceeds as normal)

StreamCancel with reason = BARGE_IN is how the core aborts its own render stream; the satellite stops playback and reports what it managed to play. The new exchange (id 3, odd — the satellite’s next) runs exactly like the first.

4.4. Tap-to-stop

Barge-in above replaces the turn with a new one. When the user instead just stops — a screen tap, a stop button — with no replacement command, the satellite sends Stop: a stateless turn-abort that carries no stream id and depends on no open stream.

   (core mid-turn: thinking — mic already closed, TTS not yet open)
Sat → Core   Stop   {no id}   ← user taps stop while it is thinking
   (core cancels the in-flight activation and lands the device idle)

Stop exists because a turn in the processing phase has nothing on the wire to cancel — the capture already ended and no render has opened — so a stream-scoped StreamCancel would reach nothing. Stop and StreamCancel are orthogonal: Stop ends the turn, StreamCancel discards one stream. Aborting a turn that still has an open capture or render sends both — Stop to cancel the activation, a StreamCancel on each open id to tear it down. Stopping a standalone media-sink (music) stream sends StreamCancel on its id alone, with no Stop: that is a transport stop, not a turn abort. StreamCancel never cancels an activation on its own; Stop is the only turn-abort signal.

4.5. An error along the way

When a request fails, its reply is a typed Error instead of the success terminal — on the same id. Here the screen.capture invoke fails because the display is asleep; the core adapts and still finishes the exchange:

   (… mid-exchange, the core invokes a tool …)
Core → Sat   Invoke           {id 4, name "screen.capture"}
Sat → Core   Error            {id 4, code UNAVAILABLE, message "display asleep"}   ← not a ToolResult
   (core adapts: replies "I can't see your screen right now")
Core → Sat   MediaRenderOpen  {id 2, SPEAKER}  → … → Response {id 1}

An Error carries {code, message, data} and terminates whatever request it answers — here the Invoke’s id 4. UNAVAILABLE means the capability is advertised but momentarily out of reach (display asleep, speaker muted): the operation is rejected on its own id, never a crash and never a silent drop, so the exchange carries on. The same shape can end a whole exchange — an Error on the CaptureOpen id (say INTERNAL_ERROR) aborts it, the failure counterpart to Response.

5. Transport

Alabama is a session layer: the framing, the operation catalogue, identity, and admission are defined independently of the substrate that carries them. Alabama does not care which transport carries it, only that the transport meets a small binding contract. A transport binding MUST:

Each binding pins its own cryptography and its own message framing; the session layer above is identical on every one. Version 1 ships a single binding, Noise over TCP, and a future binding (QUIC [RFC9000], whose handshake is secured with TLS 1.3 [RFC9001]) slots in beside it as an additional listener with no change to anything above the transport.

5.1. Noise over TCP

The version 1 binding. [NOISE] The Noise static keypair is the device identity; the handshake proves possession of the key and encrypts the whole channel. Noise has a small internal footprint that coexists with the on-device audio front end on RAM-constrained hardware (ESP32), and it carries phones and desktops over the same binding. The handshake patterns — IK when the satellite already knows the server’s static key, XX when it does not — are in § 7.2 Noise handshake patterns.

5.1.1. Cipher suite

The suite is fixed:

**Noise_IK_25519_ChaChaPoly_SHA256** — when the satellite already knows the server’s static key (provisioned out of band).
**Noise_XX_25519_ChaChaPoly_SHA256** — when it does not, exchanging and pinning both keys on first use.

Both share the same primitives: Curve25519 [RFC7748] for the Diffie-Hellman, ChaCha20-Poly1305 [RFC8439] for the AEAD, and SHA-256 for the hash. These are the canonical 25519 / ChaChaPoly / SHA256 Noise tokens, and the suite name follows the Noise Noise_<pattern>_<DH>_<cipher>_<hash> form ([NOISE] §8). The pattern (IK or XX) is the satellite’s choice per § 7.2 Noise handshake patterns; the responder accepts either. A peer that offers any other suite is rejected at the handshake.

ChaCha20-Poly1305 is designed to run in constant time in pure software — it uses only add/rotate/XOR with no key-dependent table lookups ([RFC8439] §1) — with no dependence on an AES hardware path that a heterogeneous third-party device may not have, and SHA-256 is hardware-accelerated on the ESP32-S3 the firmware targets ([ESP32-S3-DATASHEET], SHA accelerator) and matches the ESPHome substrate Alabama draws on (§ 14.2 ESPHome native API).

5.1.2. Message framing

TCP is a byte stream with no message boundaries, and a Noise transport message is itself bounded — at most 65535 bytes ([NOISE] §3, the 16-bit length field), of which 16 are the Poly1305 tag, leaving 65519 plaintext bytes. So each Noise message — every handshake message and every transport message — is written to the TCP stream as a 2-byte big-endian length followed by exactly that many bytes of Noise message. The reader takes the length, reads the message, and decrypts it.

This framing is the Noise binding’s, distinct from the application frame’s own total_len (§ 6 Wire encoding and framing). The decrypted plaintext of successive transport messages concatenates into one continuous stream, and Alabama application frames are parsed from that stream by their total_len prefix — independent of where the Noise message boundaries fell. One application frame MAY span several Noise messages, and several small frames MAY share one. The two length layers never need to align: a StreamChunk larger than 65519 bytes is carried across as many Noise messages as it takes, while the negotiated max_frame_bytes (§ 6.1 Frame size limits) bounds the application frame as a whole.

6. Wire encoding and framing

Every frame is binary protobuf [PROTOBUF], length-prefixed.

┌───────────────┬────────────────┬─────────────────────────┬───────────────────┐
│   total_len   │   header_len   │   Envelope (protobuf)   │    raw payload    │
│     varint    │     varint     │     header_len bytes    │   optional tail   │
└───────────────┴────────────────┴─────────────────────────┴───────────────────┘
total_len

varint — the byte count of everything after it (the header_len varint, the Envelope header, and any payload). Bounded per § 6.1 Frame size limits.

header_len

varint — the byte count of the Envelope header that follows.

Envelope

the binary-protobuf frame header, carrying exactly one operation (see § 6.2 The Envelope).

raw payload

present only on a StreamChunk frame — zero-copy audio or video a device passes straight to its output without decoding the header.

The length prefix is intrinsic to a byte-stream transport such as the version 1 Noise-over-TCP substrate, where frame boundaries are not otherwise marked. A future message-framed transport (a QUIC DATAGRAM, say) is already self-delimiting and would omit the prefix.

6.1. Frame size limits

A length-prefixed frame is only safe if the reader can bound what the prefix claims before it allocates: an unbounded total_len is a denial-of-service against a device with kilobytes of RAM. Two limits bound it.

A fixed handshake ceiling bounds the frames that arrive before anything is negotiated: a Hello and its HelloResponse MUST each fit in a single Noise transport message — at most 65519 plaintext bytes, the 65535-byte Noise message limit less its 16-byte authentication tag (§ 5 Transport). They are pure control — a surface declaration and an outcome — so a single message suffices, and it keeps the pre-negotiation reader free of any reassembly state: it decrypts one message and holds the whole frame. A receiver rejects a larger handshake frame outright, since it cannot trust a length it has not yet been told to expect.

Past the handshake the frame size is negotiated, per direction. Each peer advertises the largest frame it is willing to receive: Hello.max_frame_bytes (the satellite’s receive limit) and Accepted.max_frame_bytes (the core’s). The sender MUST NOT emit a frame whose total_len exceeds the peer’s advertised limit — it chunks a StreamChunk tail, streams a large image over ImageRenderOpen (§ 10.5 Render operations) or large input media over UploadOpen (§ 10.4 Input operations) rather than inlining it, or shrinks an inline ContentBlock to stay under it. The limit governs the whole frame, header and tail together, so it bounds both a streamed media tail and a large inline block carried in the Envelope itself. A value of 0 means unset, and a protocol default of 65516 bytes applies — the largest frame that still rides a single Noise transport message, so a default-configured peer never reassembles a frame across Noise messages. A satellite with a tight RAM budget — an ESP32-S3 with the AEC and wakeword stack resident — advertises a small limit and the core paces under it; a capable device raises it.

A receiver sent an over-limit frame anyway MAY reject it with Error{code = FRAME_TOO_LARGE} on the frame’s id, or — when the header itself exceeds what it can decode — close the connection, since it can no longer locate the next frame boundary.

6.2. The Envelope

Exactly one operation rides each frame, in a flat oneof. The envelope id correlates a request with its reply.

6.3. Identifiers

The envelope id correlates a request with its terminal, and threads the frames of the stream that request opens (§ 10.3 Streaming operations).

It is 32-bit unsigned and allocated by the peer that originates the request. The low bit encodes the originator: a satellite allocates odd values, the core allocates even values — so the two never collide, and the parity of any id tells you who minted it. The satellite, as the connection initiator, takes the odd half, matching HTTP/2’s client-odd / server-even split (§ 14.4 gRPC and HTTP/2).

An Open (CaptureOpen, MediaRenderOpen, TextRenderOpen) mints a fresh id. Every frame of the stream it opens — StreamChunk, StreamEnd, StreamCancel, and the stream’s MediaControl — carries that id, and the terminal (Response, PlaybackDone, a ToolResult for an Invoke) answers on it. A StreamCancel is the exception that needs no separate terminal: it is one, retiring its id unilaterally on both sides (§ 10.3 Streaming operations). An id outlives its own stream: a mic stream ends at StreamEnd while the id stays open until the Response (§ 10.4.1 What the satellite observes). The stream is a phase of the id. Audio and video that belong together travel muxed in one container stream (§ 10.5 Render operations).

Within its parity class an allocator counts monotonically, stepping by two so the low bit is preserved, and wraps on overflow: ids are short-lived (an exchange, a stream), so a value is long retired before its number comes round again. The value 0 is the absent id — a frame with no id is a pure notification (a spontaneous SettingState, Presence, an observe copy) — so it is never allocated, and the core’s even sequence starts at 2.

7. Identity and admission

7.1. Device identity is the transport key

Every satellite generates a static keypair on first start. The public key is the device identity; the core derives an internal device_id = fingerprint(pubkey) that keys the registry, settings, and the tool namespace. The Hello carries no identity fields — the core reads the authenticated device key from the transport.

7.2. Noise handshake patterns

The satellite chooses its pattern by whether it already knows the server key; the core (responder) accepts both.

sequenceDiagram
    autonumber
    participant Sat as Satellite
    participant Core
    Sat->>Core: TCP connect
    alt server key known (provisioned via QR/BLE/ggwave)
        note over Sat,Core: Noise IK — anti-MITM from the first byte
    else cold device, address only
        note over Sat,Core: Noise XX — both keys exchanged, both TOFU
    end
    Sat-->>Core: static pubkey (authenticated in-handshake)
    Core-->>Sat: server pubkey + ephemerals → channel encrypted

7.3. Admission

The handshake (§ 7.2 Noise handshake patterns) authenticates the device’s static public key before the first Hello, and the core derives device_id = fingerprint(pubkey) and looks it up in its registry.

A device_id the core has not seen is pinned on first use and admitted as a guest: the core MUST reply Accepted and MUST enforce the guest surface server-side. A returning device whose authenticated key matches its pin is admitted at the trust the core already holds for it. The device is told nothing of its trust level — there is no trust field on the wire — so it runs identically as a guest or a fully paired device. Trust is elevated through one of three paths:

Token path (recommended for new devices)

The admin generates a short-lived pairing token on the server (e.g. by clicking "Add Device") and delivers it to the satellite out-of-band —​ggwave [GGWAVE] data-over-sound, a QR code, or BLE. The satellite includes the token in Hello.pairing_token. The core validates the token (checks it exists in the pending set and has not expired) and, if valid, MUST automatically send Paired (§ 10.2 Control operations) on the established session without any further admin action. Two proofs compose: the Noise handshake proves the device holds the matching private key; the token proves physical proximity at provisioning time (the device had to receive the out-of-band broadcast). Together they guarantee "this specific device was intentionally provisioned."

Manual in-band path

The satellite connects as a guest via Noise XX. An admin inspects the device in the management UI — identified by its key fingerprint and self-reported display name — and manually approves it. The core sends Paired on the live session.

Pre-provisioned path

Out-of-band provisioning (QR code, BLE, ggwave) delivers the server’s static public key to the satellite before its first connection. The satellite arrives already using Noise IK and is admitted at whatever trust the admin pre-configured. Paired is never sent on this path —​the satellite already has the key pinned.

Because the identity is the key, a different key is simply a different device_id, admitted as a new guest — the handshake proves possession of the matching private key, so impersonation is impossible by construction. A device that regenerates its keypair becomes a new identity and re-pairs from scratch; a satellite is cheap to re-enrol.

The one key mismatch that matters is the satellite’s: under the IK pattern it has pinned the server’s static key and MUST abort if the server presents a different one — this is what makes IK anti-MITM from the first byte.

A satellite that has not yet pinned a server key connects with XX (TOFU). It MUST NOT persist the server key on XX alone — the key is pinned only when the core sends a Paired message on the established session (§ 10.2 Control operations). Until then the satellite is a guest: it holds the server key for the lifetime of the current session only and uses XX again on reconnect. Once Paired is received the satellite MUST persist the server’s static public key and use IK for all subsequent connections. A satellite that has pinned a server key MUST NOT fall back to XX on IK failure — doing so would silently accept a different core and discard the pairing. If address discovery returns multiple candidates (e.g. via DNS-SD when a dev and a production instance share a network), the satellite SHOULD attempt IK against each candidate in turn and connect to the first one whose key matches the pin. A satellite MUST NOT replace a pinned server key without an explicit factory-reset or re-provisioning flow initiated by the user.

A server MAY operate in closed mode, in which it rejects Hello from any device_id it has not previously registered. In closed mode the core replies HelloResponse{rejected: Rejected{reason: REJECTED_CLOSED}} and closes the connection. The satellite MUST NOT retry automatically; the device requires user-initiated factory-reset or explicit provisioning to reach a server that will admit it. Closed mode is a server configuration knob, not a wire negotiation — a satellite has no way to query or change it.

HelloResponse carries Accepted, VersionMismatch, or Rejected (§ 13 Version gate and compatibility). In the normal path — open-mode server, compatible version —​admission never denies an authenticated peer a session; it only bounds what the session can do.

8. Surface declaration

A satellite declares its surface in the Hello across four orthogonal planes. The field tables for every message here are in Appendix A: Full wire schema.

8.1. Capabilities versus tools

The dividing line: a capability is something the core must understand in advance to take initiative — it is a closed, typed enum with exact parameters, and the core has hard-wired behaviour per value (it resamples to SpeakerParams.rate, gates a MIME against accepts). A tool is a discrete, on-demand action with an open JSON schema, called by name. Input that only the satellite initiates is not declared at all.

8.2. Tools, events, settings

The tools plane carries only name, description, and input_schema. The events plane is the inverse of a tool — a device-emitted signal, whose meaning the user configures in the management UI, never on the wire. The settings plane renders the device’s own settings generically.

Each plane carries normative weight:

9. Content blocks

The typed, discrete content unit carried in tool results and one-shot render pushes. Streaming media rides the frame payload instead. Field tables are in Appendix A: Full wire schema.

10. Operations

Every wire verb is a typed arm of the Envelope oneof. This section walks them grouped by function — each with its tier and, for Tier 2, the capability, tool, event, or setting that gates it; the field-level detail for every message is in Appendix A: Full wire schema. § 10.9 Capability mapping inverts the view, mapping each declared part of the surface to the operations it unlocks.

10.1. Delivery classes

Each operation has an intrinsic delivery class — a property of its type, not a wire field — with one mode-dependent exception for capture. LOSSY_OK covers the stream chunks of MediaRenderOpen and of a **LIVE** CaptureOpen: individually droppable media frames, where a missing frame is normal and not an error. RELIABLE covers everything else — every stream open, end, and cancel, every control, result, and lifecycle frame, *and the chunks of a DEFERRED capture*. A DEFERRED upload is store-and-forward precisely so a recording made while disconnected survives intact; dropping its frames would defeat the one capture mode whose entire purpose is durability, so its chunks are reliable even though a LIVE capture’s are not. Delivery class thus keys on the capture’s mode, not on the StreamChunk type alone.

On version 1’s single reliable Noise-over-TCP connection nothing is dropped in transit, and the transport offers no help here: TCP exposes only connection-global flow control — no per-stream backpressure, and no way to drop a frame — so left to the transport, a slow consumer would stall the whole multiplexed connection and delay RELIABLE frames behind buffered media. The core therefore reads LOSSY_OK as its licence to run its own application-level backpressure: rather than let media back up, it elides the stale frames (drop-oldest, pacing) the transport itself would never drop. So LOSSY_OK authorises the core to drop on purpose; under a future datagram transport (§ 5 Transport) it becomes literal transit loss, mapping to unreliable QUIC DATAGRAM frames ([RFC9221], which are not retransmitted on loss). A RELIABLE stream — a DEFERRED capture among them — rides a reliable QUIC stream under that transport, never a datagram, so a stored recording is never lost in transit.

A satellite declares its playout-buffer depth, buffer_ms, on a render capability (SpeakerParams, MediaSinkParams), and the core paces a live render within it. How the core schedules and bounds its own sending is server-internal and out of scope here; the wire contract is only that LOSSY_OK frames may be dropped, RELIABLE frames may not, and that the satellite advertises the buffer depth the core paces to.

The single connection multiplexes every stream and every control frame, so a large RELIABLE transfer — a chunked image, a DEFERRED upload — MUST NOT starve liveness. A sender MUST interleave Tier 0 frames (Ping, PingResponse) and other control between the chunks of a bulk transfer rather than emitting the whole transfer ahead of them, so head-of-line delay for a control frame is bounded by one max_frame_bytes frame, not by the size of the transfer; it SHOULD keep its socket writes frame-granular so the kernel send buffer cannot queue bulk bytes ahead of a waiting Ping. This is discipline, not a transport guarantee: a single TCP byte-stream offers no real priority, so a liveness timeout SHOULD allow for that one-frame queueing, and a clock-offset round taken under heavy load is approximate — the four-timestamp math cancels symmetric delay but not the asymmetric queueing a saturated link adds. A future QUIC binding (§ 5 Transport) removes the coupling outright by giving each stream its own flow-controlled lane; on version 1’s single TCP stream the interleaving discipline is what keeps liveness honest.

10.2. Control operations

Tier 0 — universal and mandatory, gated by nothing: without them a peer is not a satellite. Hello opens the connection and declares the device surface (§ 8 Surface declaration); the core replies with HelloResponseAccepted (normal path), VersionMismatch (§ 13 Version gate and compatibility), or Rejected (server in closed mode, § 7.3 Admission). Ping/PingResponse carry liveness and a clock-offset round: four microsecond timestamps yield offset and round-trip time, mapping the satellite’s monotonic clock onto the server wall-clock, and nothing else; PingResponse answers on the Ping’s id. Error is a typed failure correlated by envelope id; a transiently unavailable capability (screen off, speaker muted) is rejected per-operation with code = UNAVAILABLE, and an Error on a CaptureOpen id means the input on that id was cancelled or failed. Paired is sent core→satellite on the token path (valid Hello.pairing_token) or the manual in-band path (admin approves via the management UI) — see § 7.3 Admission. On receipt the satellite pins the server’s static public key and switches to IK for all future connections. Paired carries no payload; its meaning is fully encoded by the direction and the authenticated channel it arrives on. Devices that arrived via Noise IK (pre-provisioned path) never receive Paired — they already have the server key pinned.

10.3. Streaming operations

Tier 1 — the generic stream substrate, shared and drawn in by any streaming capability rather than gated per-capability. Concurrent streams are multiplexed by the envelope id — each Open’s id is its stream (§ 6.3 Identifiers); a StreamChunk carries one frame, with the raw audio or video payload on the frame tail (§ 6 Wire encoding and framing). StreamEnd closes a stream gracefully (finalize and process); StreamCancel aborts it and discards on both sides, subsuming barge-in. A LIVE capture streams in real time; a DEFERRED capture is store-and-forward, its chunks carrying a capture_ts (microseconds) from the device’s monotonic clock that the core maps to wall-clock via a Ping offset round, and acknowledged by CaptureAck (§ 10.4 Input operations); it survives a reconnect without duplicated frames via the CaptureOpen correlation_ref (§ 10.4 Input operations). Per-stream encoding (PCM_S16LE — signed 16-bit little-endian linear PCM — or OPUS [RFC6716]) is self-describing on the open.

Concurrency is bounded per direction, mirroring max_frame_bytes. Each peer advertises the most streams the other may hold open against it at once —​Hello.max_concurrent_streams for the core’s render streams toward the satellite (MediaRenderOpen, TextRenderOpen, ImageRenderOpen), Accepted.max_concurrent_streams for the satellite’s captures toward the core — so a kilobytes-of-RAM device is never forced to hold more reassembly state than it declared, and an arbitrary flood of opens cannot exhaust it. A value of 0 means the protocol default of 8 applies. A stream counts against the limit from its Open until its StreamEnd or StreamCancel — the window in which chunks can still arrive and cost buffer state, not the longer wait for a terminal. Only chunked streams count: a one-shot Render, an Invoke, or a Ping is request-shaped, not a stream. Opening one past the peer’s limit draws Error{code = TOO_MANY_STREAMS} on the new id, and the opener waits for an open stream to end before minting another.

StreamCancel is authoritative and unilateral: it needs no acknowledgement and has no terminal — it is the terminal. The sender treats the named id as dead the instant it emits the cancel, the receiver treats it dead on receipt, and neither waits for the other — that is what keeps barge-in instant. Any frame that arrives on a cancelled (or never-opened) idStreamChunk, StreamEnd, MediaControl — is silently discarded by the receiver; a peer that keeps emitting on a cancelled id is non-conformant, and the receiver MAY close the connection if it persists. There is no "refuse" on the wire: a peer cannot decline a StreamCancel. A device that physically cannot interrupt an in-progress effect degrades in quality but sends nothing back — the canceller has already moved on. Finally, StreamCancel ends one stream instance, not a streaming mode: an always-streaming device whose ambient CaptureOpen is cancelled MAY immediately open a fresh ambient CaptureOpen on a new id to resume. Cancel rotates the id; it does not revoke always-streaming.

10.4. Input operations

Tier 2. CaptureOpen, TextInput, and Event are satellite-initiated and not declared as capabilities — the core reacts to the frame that arrives. CaptureOpen opens an input stream and its envelope id is the correlation handle for the whole input: a present trigger makes it a discrete input the core processes and replies to, an absent trigger makes it ambient. A device MAY open a single ambient CaptureOpen at connect and stream continuously, letting the core run wakeword detection server-side rather than on the device. The id’s terminal is a Response or Error, and it outlives the audio: the microphone stream ends at StreamEnd while the core keeps processing (§ 6.3 Identifiers). TextInput is a discrete text input from a chat satellite, optionally carrying MediaBlock images alongside the text — a chat satellite forwarding a photo attaches it there rather than opening a capture. An image that fits the core’s max_frame_bytes rides inline in the block’s data; a larger one streams ahead over UploadOpen (below) and the block carries only the upload’s id in MediaBlock.upload. TextInput carries a from (the sender) and an opaque lane key, both owned by the satellite. from identifies the person; the core resolves it to a user. lane identifies the room and is set ONLY when the message is not a private 1:1 (a group chat): the core then keys conversation history on the lane rather than merging every chat on that connection into one thread, and treats the turn as shared context — no private per-speaker memory recall, since a shared room must not leak one person’s private facts. A private DM leaves lane empty and keys on from. The same from/lane pair rides CaptureOpen for voice turns. Event is a device-emitted signal whose meaning the user configures (§ 8 Surface declaration).

UploadOpen is the input-direction mirror of ImageRenderOpen (§ 10.5 Render operations): a satellite→core binary upload over the stream substrate (§ 10.3 Streaming operations), for any discrete op’s media that does not fit one frame — a forwarded photo on a TextInput, a camera still on a Response, a tool result’s media on a ToolResult. The satellite opens it with the payload’s mime and exact total_bytes, the encoded bytes ride the chunk tails, and the stream ends with StreamEnd{COMPLETE}; the discrete op that follows then references the completed upload by its envelope id via MediaBlock.upload instead of carrying inline data. The transport is ordered, so by the time the referencing op arrives the core holds the whole payload; the core resolves the reference back to inline bytes before anything downstream sees the block, and the upload is consumed by the first op that references it. UploadOpen is fire-and-forward like ImageRenderOpen — no positive terminal; an Error on its id (a declared size over the core’s budget, a stream that ends short of or runs past total_bytes) is the only answer and voids the id, and a later op referencing an unknown, incomplete, or voided upload draws Error{INVALID_REQUEST} on the referencing op’s id. An upload never outlives its connection, and it counts against Accepted.max_concurrent_streams from its open to its StreamEnd like any satellite-opened stream. As a bulk transfer it obeys the interleaving discipline (§ 10.1 Delivery classes): control frames go between its chunks, never behind the whole transfer. The core MUST NOT send UploadOpen or reference MediaBlock.upload itself — core→satellite media streams over ImageRenderOpen / MediaRenderOpen instead. RequestCapture is the one core-initiated input verb, gated by the MIC capability (or IMAGE_CAPTURE for a still-frame grab, below): the core sends it on an id R carrying a correlation_ref token (enrollment, a follow-up question, a cross-device trigger), and it is request-shaped — the device MUST answer, never leaving R unanswered. The answer is exactly one of two frames on that correlation. Accept is a CaptureOpen echoing correlation_ref equal to the request’s token — the input-side echo the LIVE path otherwise lacks, and for a DEFERRED upload the same field doubles as the resumable handle; that correlated CaptureOpen is R’s positive terminal, and the capture then streams and ends like any input (§ 10.4.1 What the satellite observes). Decline is an Error on R with code = UNAVAILABLE — the capability is advertised but transiently unavailable, i.e. the device is busy or has no capture capacity right now. Because the decline arrives as a frame rather than a timeout, the core learns of the refusal immediately and falls back on its own policy (reuse an already-running ambient stream, retry, or surface the failure) instead of waiting one out.

When RequestCapture sets image_source (gated by the IMAGE_CAPTURE capability, not MIC), it is a still-frame grab rather than an audio stream: the device captures one frame of the named source kind — webcam or screen, one the device listed in ImageCaptureParams.kinds — and answers R directly with a Response carrying the frame as a single MediaBlock content block: inline when it fits the core’s max_frame_bytes, streamed ahead over UploadOpen and referenced via MediaBlock.upload when it does not. This is a discrete request/response: no CaptureOpen, no capture stream. A device that cannot produce the frame declines with an Error on R. This is how the core’s image orchestrator pulls a picture from a device that advertised IMAGE_CAPTURE.

Concurrency for a requested capture is bounded by Accepted.max_concurrent_streams (§ 10.3 Streaming operations). A device with spare capacity opens the requested CaptureOpen concurrently with any capture it is already running (e.g. an ambient stream). A device at capacity — including a single-slot, always-streaming device — SHOULD supersede rather than decline: it ends or pauses the ambient capture, serves the requested one, and resumes the ambient capture on the requested capture’s close, because the core’s explicit request outranks the device’s default ambient mode. Only a device that genuinely cannot serve MUST decline with Error{UNAVAILABLE}.

CaptureAck is the cumulative acknowledgement for a DEFERRED upload — it belongs to the deferred-capture path, not the universal stream substrate, so a LIVE capture never sees one.

A DEFERRED upload survives a dropped connection and resumes without duplicating frames the core already stored. The envelope id cannot carry the resume — it is per-connection and resets on reconnect — so CaptureOpen carries an optional correlation_ref, a stable handle the device reuses across reconnects (the input-side counterpart to RequestCapture.correlation_ref). To resume, the device reopens the DEFERRED CaptureOpen with the same correlation_ref; the core maps it to the partial state it holds and re-issues its high-water-mark CaptureAck on the new id, so the device drops the frames already stored and continues from there — closing the duplication window even when the last pre-disconnect CaptureAck was lost. correlation_ref is meaningful only for DEFERRED: a LIVE capture is real-time and stale on reconnect, so it carries none and dies with its connection (a drop draws Error on its id, and the user re-triggers).

10.4.1. What the satellite observes

The satellite needs no notion of a "turn" — the core pushes its pipeline phase explicitly with PipelineState, a spontaneous notification (id = 0, § 10.10 Per-operation rules) sent per-connection, never broadcast. phase is one of IDLE, LISTENING, PROCESSING, SPEAKING, or ERROR (error_code set only on ERROR); the satellite renders it — LEDs, a face, a status pill — and no longer infers phase from the frame sequence. This covers turns a satellite cannot otherwise see coming, such as a captureless announcement, a text/chat exchange, or a render routed to a different sink, and keeps a multi-segment turn from flickering between segments.

PipelineState is advisory and UI-only, decoupled from mic control: a satellite MUST NOT drive its microphone from it. The mic lifecycle stays owned by RequestCapture, EndCapture (StreamEnd{END_DETECTED}), and StreamCancel (§ 10.4 Input operations).

The capture stream’s own terminal is a separate concern from the UI phase: a CaptureOpen always ends in exactly one Response (done) or Error (aborted) on its id, and the satellite returns to idle from that input from this terminal alone (§ 10.8 Results and terminals). How the core detects end of input, decides, and synthesizes stays server-internal and off the wire —​PipelineState narrates it for display, it does not expose it.

10.5. Render operations

Tier 2 — server-produced, each gated by the playback or display capability it targets. MediaRenderOpen opens a finite TTS render on a SPEAKER or a long-lived A/V stream on a MEDIA_SINK; TextRenderOpen opens a text stream into a TEXT_DISPLAY (transcript, assistant, or karaoke); ImageRenderOpen streams a still image into an IMAGE_DISPLAY; Render is a one-shot push to a display surface (IMAGE_DISPLAY, HTML_DISPLAY, or TEXT_DISPLAY); MediaControl steers a MEDIA_SINK’s playout buffer (pause, resume, flush) and sets its playout volume (set-volume, which doubles as the duck level while the assistant speaks over a running stream).

Every render open (MediaRenderOpen, TextRenderOpen, ImageRenderOpen, Render) carries an optional to (the person, the echo of the driving input’s from) and lane (the room, set for a group). A satellite multiplexing many conversations over one connection delivers the stream to coalesce(lane, to) and routes every later frame of the stream by its envelope id; a single-conversation device leaves both empty. This is what lets one bridge run many chats concurrently without a global in-flight lock.

An image reaches an IMAGE_DISPLAY by one of two paths, chosen by size. An image that fits the negotiated frame rides a single one-shot Render with the bytes inline. An image too large for one frame streams over ImageRenderOpen: the encoded bytes ride the chunk tails, the device sizes a buffer from the open’s total_bytes, reassembles, and paints at StreamEnd — the same stream substrate (§ 10.3 Streaming operations) audio and video already use, so the per-frame working set stays bounded by max_frame_bytes while the image itself may be far larger. This frees the image size from the frame size: a RAM-tight panel can advertise a small max_frame_bytes and still receive a large picture, holding only one chunk in transit plus the reassembly buffer. ImageRenderOpen is fire-and-forward like TextRenderOpen — no terminal, just a StreamEnd, and an Error on the id if the device cannot allocate the buffer or display the surface. The core MUST NOT leave a capable display blank for want of a path: it downscales or recompresses the image until it fits — into one frame for an inline Render, or under the device’s buffer for the stream — so a panel always receives a viewable image rather than nothing.

The HTML_DISPLAY surface renders sandboxed markup, and sandboxed is normative: the markup the core pushes is often assembled from model or tool output and is treated as untrusted, so a satellite advertising HTML_DISPLAY MUST render it in an isolated context with no ambient authority over the host — no access to the device filesystem, native or device-capability APIs, persistent storage, or the local network, and no navigation away from the pushed document. The content is presentational only. How a satellite isolates it — an OS webview’s sandbox, an iframe sandbox attribute ([HTML], which removes script, form, same-origin and top-navigation authority unless re-granted by token), a restricted renderer — is the satellite’s concern and not on the wire.

The isolation is a trust assumption, not a property the core can attest. The core pushes untrusted, often model-generated markup to a surface it cannot inspect: it knows only that the device advertised HTML_DISPLAY, not that the device’s renderer actually sandboxes. For a published wire whose whole point is third-party satellites, the sandbox is therefore required by this spec and honoured by convention — every implementer’s good faith — rather than enforced by the protocol. A conformant satellite MUST isolate as above; a core MUST treat the guarantee as contingent. To bind that unverifiable surface to the one thing it can verify — device identity — a core SHOULD push HTML_DISPLAY only to an elevated, paired device (§ 7.3 Admission), never to a guest, so untrusted markup never reaches an unattested renderer admitted on first contact alone. HtmlDisplayParams carries only the viewport the core authors against (§ 8 Surface declaration).

Now-playing metadata — the title, artist, and artwork of a running MEDIA_SINK stream — is not a media-plane concern and has no dedicated operation. It is display content: the core pushes it as a one-shot Render to a TEXT_DISPLAY or HTML_DISPLAY, the structured fields riding as JSON in the ContentBlock text and any artwork as an inline MediaBlock. Playout position is not streamed — the device is the one playing the audio, so it owns its own scrubber; the core sends the static metadata once when the stream opens and again as a cleared state when it stops. A Render carries no role, so a device that wants to distinguish a now-playing card from other display content relies on the surface it targets; a future revision MAY add an optional role to Render if a device needs to tell them apart on one surface.

10.6. Tool operations

Tier 2. Invoke calls a satellite-hosted tool by name — gated by the tools the device declares in its Hello — and the device replies with a ToolResult on the id. With observe = true the pair is an informational, server-redacted copy of the active exchange, delivered only to satellites that advertise the TOOL_ACTIVITY capability. Source, trust, namespacing, and routing are server-internal (§ 12 Tool federation).

10.7. Device operations

Tier 2. SettingsWrite tells a device to apply one of its declared settings and the device replies with a SettingState on the id; a SettingState sent without an id is a spontaneous report the core observes. Presence is optional multi-user ambient context — who is in the channel — reusing the from ref type (§ 11 Speaker identity); it is not an event and opens no input.

10.8. Results and terminals

Every request-shaped operation has exactly one terminal correlated on its own envelope id: a Response terminates a CaptureOpen, a PlaybackDone terminates a render stream (MediaRenderOpen), and a ToolResult (above) terminates a tool call (Invoke). RequestCapture is request-shaped too: its terminal on R is the accepting CaptureOpen that echoes its correlation_ref (§ 10.4 Input operations), and the failure counterpart is a decline Error{UNAVAILABLE} on R — the device MUST send one or the other, never leaving R unanswered. An Error (above) on that id is the failure terminal for any of them. A CaptureOpen therefore always ends in exactly one Response (done) or Error (aborted) on its id — including a voice exchange whose audio left over a separate MediaRenderOpen stream, where the Response carries no content and is a pure completion signal. A satellite returns to idle from this terminal alone; it never infers completion from a render stream.

Render, TextRenderOpen, and ImageRenderOpen are fire-and-forward and carry no terminal: a one-shot Render is delivered reliably, so the absence of an Error is the success signal, and TextRenderOpen and ImageRenderOpen each end at their StreamEnd. Each still fails loudly — an Error on its id reports a surface that could not display.

10.9. Capability mapping

What each declared part of the surface (§ 8 Surface declaration) unlocks. A satellite that does not advertise a row neither sends nor receives its operations; an older device that advertises fewer rows simply never sees the newer arms.

Declaration Unlocks
— (satellite-initiated, undeclared) CaptureOpen, UploadOpen, TextInput
MIC RequestCapture
SPEAKER MediaRenderOpen (finite TTS)
MEDIA_SINK MediaRenderOpen (live A/V), MediaControl
TEXT_DISPLAY TextRenderOpen, Render (text)
IMAGE_DISPLAY Render (image), ImageRenderOpen
HTML_DISPLAY Render (HTML)
TOOL_ACTIVITY Invoke / ToolResult observe copies
IMAGE_CAPTURE RequestCapture (still frame, image_source set) → Response
declared tool InvokeToolResult
declared event Event
declared setting SettingsWriteSettingState

The stream substrate (§ 10.3 Streaming operations) and control operations (§ 10.2 Control operations) are not in this table — they are universal, not gated.

10.10. Per-operation rules

The rules every operation obeys, beyond the field tables (Appendix A: Full wire schema):

Correlation

A request-shaped operation gets exactly one terminal on its own id — a Response, PlaybackDone, or ToolResult, or an Error in its place (§ 10.8 Results and terminals). The originator mints the id at its parity (§ 6.3 Identifiers); the responder MUST answer on that same id and MUST NOT mint a new one for the reply. A frame with id = 0 is a notification and MUST NOT be answered.

Streams

A StreamChunk’s seq starts at 0 and increments by one. On a LOSSY_OK stream (§ 10.1 Delivery classes) the receiver MUST tolerate gaps; on a RELIABLE stream there are none. After a StreamEnd or StreamCancel on an id, the sender MUST NOT send further chunks on it; a chunk for an unknown or closed stream draws Error{code = UNKNOWN_STREAM} or STREAM_CLOSED.

Concurrency

A peer MUST NOT hold more concurrently open streams against the other than its advertised max_concurrent_streams (§ 10.3 Streaming operations, default 8); an Open past the limit draws Error{code = TOO_MANY_STREAMS} on the new id.

Capability gating

A peer MUST NOT send a Tier 2 operation the other side has not unlocked (§ 10.9 Capability mapping). A core that receives an operation for an undeclared capability answers Error{code = CAPABILITY_MISSING}; a capability that is declared but momentarily unusable (screen asleep, speaker muted) draws UNAVAILABLE on the operation’s id, never a connection drop.

Frame size

A sender MUST NOT emit a frame whose total_len exceeds the peer’s advertised max_frame_bytes (§ 6.1 Frame size limits), chunking a tail, streaming a large image over ImageRenderOpen, streaming large input media over UploadOpen, or shrinking an inline block to stay under. A receiver MAY reject an over-limit frame with Error{code = FRAME_TOO_LARGE}.

Liveness

Either peer MAY send a Ping at any time; the responder SHOULD answer promptly with a PingResponse on the same id, stamping t2 on receipt and t3 on send (§ 10.2 Control operations).

Forward compatibility

A peer MUST ignore envelope fields and message fields it does not recognise, and MUST tolerate an unrecognised oneof arm rather than drop the connection — the wire is append-only (§ 2.2 Extensibility), so an unknown operation from a newer peer is simply not acted on.

11. Speaker identity

Who produced an input travels per-input on one optional field, from: a single opaque string passed through verbatim by the satellite. The core matches it against its registry by exact string comparison; it never parses it. It rides on CaptureOpen, TextInput, and Event, and Presence reuses the same ref type. Resolution to a user — binding, voiceprint, channel credential —​is entirely server-internal and out of scope for this wire.

The hint carries no authority. A satellite — including a guest or a compromised peer — can place any string in from, so a core MUST NOT derive a speaker’s permissions from it: authority follows from the authenticated device key (§ 7.3 Admission), a voiceprint, or a channel credential, never from the hint. What a paired device represents sets how far this reaches — a device paired to one person carries that identity in its device key and from adds nothing, while a device paired to a shared space fronts many speakers behind one key and needs the separate signal above. The advisory Hello.multi_speaker flag only defaults the pairing UI toward one binding or the other; it informs that choice, never makes it, and a core MUST NOT derive authority from it either.

12. Tool federation

The wire carries only two things for tools: the ToolDescriptor in the Hello (§ 8 Surface declaration) and the Invoke/ToolResult pair (§ 10.6 Tool operations). Everything else — source, trust tier, qualified naming, collision resolution, routing across satellite, builtin, and MCP backends — is derived inside the server and never travels on the wire. Tool observation is gated by the TOOL_ACTIVITY capability: Invoke/ToolResult with observe = true are server-redacted copies of the active exchange, delivered only to satellites that advertise it.

13. Version gate and compatibility

Satellites and the core are updated independently — a phone app, an OTA firmware push, and the user’s own server each move on their own schedule. So version skew is the normal state, not a coordinated event: updating one side never breaks the other, and there is no flag day.

Compatibility is structural, not negotiated by comparing version numbers. The wire is append-only (§ 2.2 Extensibility), so any two versions interoperate on their common subset, and capabilities (§ 8 Surface declaration) negotiate which features a given device actually has. A newer satellite talking to an older core — or the reverse — simply uses what both understand.

Hello.wire_version is field 1, frozen forever: the one anchor every epoch can parse. It is a floor, not an exact gate — the core accepts any peer it can speak, which under the append-only rule is every normally-evolved one. It exists so that the single situation the wire cannot absorb — a peer below the baseline the core still supports — fails legibly: a typed VersionMismatch on HelloResponse (§ 10.2 Control operations) carrying the server version, the management UI flagging the device, the device signalling locally. In normal operation it never rejects.

Dead arms are not garbage-collected by rejecting old devices — that breaks end users — so they are left in place; the field-number space is vast and an unused arm costs nothing.

A genuinely breaking change — a framing or handshake flaw that cannot be fixed additively — should be vanishingly rare. When one is unavoidable, the core absorbs it: it speaks both the old and the new framing across a long deprecation window, and satellites migrate at the user’s pace. The compatibility burden sits on the core — the central, routinely-updated side — never on the user as a forced simultaneous update.

14. Prior art and alternatives

Alabama is deliberately a coupled protocol — an application-agnostic substrate (§ 2.1 Tiers) plus the aleph voice-assistant operation surface over it —​not a generic transport. It exists because the closest alternatives each settle one layer well, but not the whole.

14.1. Wyoming

Wyoming [WYOMING] is the direct inspiration, and the name is a nod to it — one US state for another. Aleph began on Wyoming and outgrew it: Wyoming is a deliberately simple voice protocol — a newline-delimited JSON header with an optional binary payload — and pushing aleph’s richer surface onto it ran into JSON parse overhead, the bandwidth of string keys on every audio frame, and mounting complexity. Length-prefixed binary protobuf removed all three. Alabama is Wyoming’s successor in spirit: voice-coupled like Wyoming, but typed, encrypted, with a device identity and a far richer operation surface.

14.2. ESPHome native API

ESPHome’s native API [ESPHOME-API] is the closest serious prior art at the substrate level, and it independently made the same low-level choices: length-prefixed protobuf over TCP, optional Noise encryption, and a Hello-handshake opening. That convergence is evidence the substrate is not gratuitous reinvention. It does not fit as-is because it is coupled to Home Assistant’s entity/service model and to ESPHome’s YAML-generated firmware, and its security is a single pre-shared key (Noise_NNpsk0) with no public-key device identity. Aleph’s satellites are heterogeneous — phone, desktop, chat bridge, microcontroller — and this wire is published so third parties can build their own into the same feature set, not only the satellites aleph ships. They need the capability, tool, event, and speaker-identity surface the entity API does not model, over a public-key identity with trust-on-first-use admission (§ 7 Identity and admission).

14.3. WebRTC and LiveKit

WebRTC [WEBRTC] is the real-time media stack — jitter buffering, packet-loss concealment, congestion control, NAT traversal, and mandatory encryption (the W3C API mandates DTLS-SRTP; the jitter/PLC/congestion machinery lives in the underlying IETF RTP layer) — and LiveKit [LIVEKIT] productises it with a self-hostable SFU and a voice-agent framework; its ESP32 SDK [LIVEKIT-ESP32] now reaches microcontrollers — both the ESP32-S3 this protocol targets and the more capable ESP32-P4 [ESP32-P4] (only hardware H.264 video is P4-exclusive; the S3 carries bidirectional Opus audio) — though it remains a heavyweight stack, and an explicit Developer Preview, on the S3. It is not adopted because it is a media and room transport, not a semantic protocol: aleph’s capability, tool, event, and operation surface would still have to be built on top of a data channel. A self-hosted SFU with TURN and signalling is also heavy infrastructure against the project’s "start one binary and it works" goal, and most of its value — traversal and loss resilience — would be spent on what is usually a LAN. Where it genuinely helps — robust real-time media on a capable device over a lossy link — plain WebRTC is a candidate media transport for a future binding, a sibling to the deferred QUIC option (§ 5 Transport), not a replacement for the protocol.

14.4. gRPC and HTTP/2

gRPC [GRPC] is the closest match to Alabama’s framing layer: bidirectional-streaming protobuf with multiplexing and flow control is, in the abstract, exactly what the substrate (§ 2.1 Tiers) provides, and the resemblance is not accidental. The envelope id parity convention (§ 6.3 Identifiers) is the HTTP/2 stream-identifier rule ([RFC9113] §5.1.1, which obsoletes [RFC7540]) on which gRPC is built [GRPC-HTTP2] — a peer-allocated stream identifier (31-bit unsigned in HTTP/2, the field’s high bit being reserved; Alabama widens it to the full 32 bits) whose low bit encodes the originator so the two sides never collide. The satellite, as the connection initiator, takes the odd values and the core the even — the same client-odd / server-even split as HTTP/2. The substrate is, deliberately, a re-derived slice of HTTP/2.

gRPC itself is not adopted, for three concrete reasons, each rooted in the ESP32-S3 target:

HTTP/2 weight

A full HTTP/2 stack — HPACK header compression [RFC7541], the SETTINGS/WINDOW_UPDATE machinery, the connection preface ([RFC9113] §3.4) — is heavy for a microcontroller already spending its internal RAM on the AEC and wakeword stack (§ 5 Transport). The substrate keeps only the one HTTP/2 idea it needs (a multiplexed, peer-parity id) and drops the rest.

Message framing versus the raw tail

gRPC frames every message as a length-delimited protobuf, which fights Alabama’s zero-copy design: here the Envelope header is protobuf but the audio or video payload rides the frame tail as raw bytes a device passes straight to its I2S audio bus [I2S-ESP-IDF] without decoding (§ 6 Wire encoding and framing). Wrapping each chunk as a gRPC message would force a copy on the hottest path.

Identity

Alabama’s device identity is the Noise static key authenticated in the transport handshake (§ 7 Identity and admission), with trust-on-first-use admission. gRPC offers no native equivalent; its security is TLS plus an application-level auth token, which does not give a public-key device identity for free.

None of these rules out gRPC as an idea source — the substrate borrows its best one — but they rule it out as the wire.

Appendix A: Full wire schema

Every message and enum, as field tables. The **canonical source of truth is the .proto** under docs/protocol/proto/ in the repository — these tables are generated from it at build time, so they cannot drift from it, and an implementer wanting the exact protobuf reads it there.

envelope.proto

Envelope — The single wire frame header for Alabama.
Field Type # Description
id uint32 1 Correlates a request with its terminal, and threads the frames of the stream it opens. Allocated by the originator; the low bit encodes who, so the two peers never collide: a satellite allocates odd ids, the core even ids (the connection initiator takes the odd half, as in HTTP/2). Monotonic (stepping by two), wraps on overflow, parity fixed. An Open (CaptureOpen, UploadOpen, MediaRenderOpen, TextRenderOpen, ImageRenderOpen) mints the id; the stream's frames (StreamChunk, StreamEnd, StreamCancel) and its control (MediaControl) carry that same id, and the terminal answers on it. 0 is the absent id — a frame with no id is a pure notification (a spontaneous SettingState, Presence, an observe copy). See the spec, Identifiers.
hello Hello (oneof op) 2 satellite opens the connection, declares its surface
hello_response HelloResponse (oneof op) 3 core's reply: accepted, version mismatch, or rejected
ping Ping (oneof op) 4 liveness + clock-offset request
ping_response PingResponse (oneof op) 5 liveness + clock-offset reply
error Error (oneof op) 6 typed failure on a request id
paired Paired (oneof op) 27 core notifies satellite that pairing is confirmed; satellite pins server key
stop Stop (oneof op) 28 stateless turn-abort — satellite cancels the in-flight turn (no stream id, any non-idle state)
stream_chunk StreamChunk (oneof op) 7 one stream frame (raw payload on the tail)
stream_end StreamEnd (oneof op) 8 close a stream gracefully
stream_cancel StreamCancel (oneof op) 9 abort a stream, discard both sides
capture_ack CaptureAck (oneof op) 10 cumulative ack for a deferred upload
capture_open CaptureOpen (oneof op) 11 open an input (mic/cam) stream — id correlates the whole input
upload_open UploadOpen (oneof op) 32 open a binary upload stream (large media for a later discrete op)
text_input TextInput (oneof op) 12 a discrete text input from a chat satellite
event Event (oneof op) 13 a device-emitted signal
request_capture RequestCapture (oneof op) 14 core asks the device to start capturing
link_identity LinkIdentity (oneof op) 29 satellite binds an external identity to a user by PIN (request)
link_identity_result LinkIdentityResult (oneof op) 30 core's terminal for a LinkIdentity request
media_render_open MediaRenderOpen (oneof op) 15 open a speaker / media-sink render stream
text_render_open TextRenderOpen (oneof op) 16 open a text stream (transcript/assistant/karaoke)
render Render (oneof op) 17 one-shot push to a display surface
media_control MediaControl (oneof op) 18 pause / resume / flush a media-sink buffer
image_render_open ImageRenderOpen (oneof op) 26 stream a large still image to a display surface
presence Presence (oneof op) 19 who is in the channel (ambient context)
settings_write SettingsWrite (oneof op) 20 tell the device to apply a setting
setting_state SettingState (oneof op) 21 the device reports a setting value
invoke Invoke (oneof op) 22 call a satellite-hosted tool
tool_result ToolResult (oneof op) 23 the result of an Invoke
playback_done PlaybackDone (oneof op) 24 terminal of a render stream
response Response (oneof op) 25 terminal of a CaptureOpen
pipeline_state PipelineState (oneof op) 31 core pushes current turn phase (UI-only, id=0)

surface.proto

Hello — The satellite's opening frame, sent after the encrypted transport handshake completes.
Field Type # Description
wire_version uint32 1 Framing floor anchor. Field 1, frozen forever — the one field every protocol epoch can parse. A floor, not an exact gate: the wire is append-only, so peers interoperate across version skew and this never rejects a normally-evolved device. The current value is 1. See the spec, Version gate.
version string 2 Free-form version label of the satellite, for telemetry. Distinct from wire_version, which is the framing floor.
capabilities repeated CapabilityDescriptor 3 The closed, typed enum of core-initiable operations the device exposes, each with exact parameters.
tools repeated ToolDescriptor 4 Open, schema'd device actions the core or model may invoke on-demand.
events repeated EventDescriptor 5 The signals the device may emit (the inverse of tools).
settings repeated SettingDescriptor 6 The device's own settings subsystem, rendered generically by the management UI.
max_frame_bytes uint32 7 The largest frame (total_len: header + payload tail) the satellite is willing to receive. The core MUST NOT send a frame exceeding it — chunking a stream tail or declining an oversized inline block to stay under. A RAM-tight device (an ESP32-S3 with AEC + wakeword resident) advertises a small value; 0 means the protocol default applies. See the spec, Frame size limits.
multi_speaker bool 8 Advisory speaker topology: true if more than one person speaks through this one paired device (a room board, a chat bridge), false for a device bound to a single user (a personal phone, smartglasses). It is a UX hint ONLY — it defaults the scope choice the pairing UI offers the admin, and nothing more. It is NEVER load-bearing: the device sets this field, so the core MUST NOT derive any authority or speaker identity from it. The authoritative scope is the admin's pairing decision, not this flag. See the spec, Speaker identity.
max_concurrent_streams uint32 9 The largest number of render streams the core may have open toward this satellite at once (MediaRenderOpen, TextRenderOpen, ImageRenderOpen). A RAM-tight device caps it low so a buggy or hostile core cannot exhaust its reassembly buffers; 0 means the protocol default applies. See the spec, Streaming operations.
pairing_token bytes 10 Optional short-lived pairing token, generated by the server and delivered to the satellite out-of-band (ggwave, QR code, BLE) before the satellite connects. When present and valid the server MUST automatically send Paired on the established session without requiring manual admin approval. The token is opaque to the protocol; its format, lifetime, and delivery encoding are implementation-defined. Absent on all subsequent connections once the satellite has pinned the server key.
device_type string 11 The satellite's self-declared device class, a short human label ("ESP32", "Android Kiosk", "Desktop", "Discord"). The core prefixes it onto the friendly default display name it mints for a new device ("Android Kiosk Satellite 1"); empty falls back to a bare "Satellite N". The device_id is an opaque Noise-key fingerprint that carries no type, so this is the only signal of what the device is. A UX label ONLY: it seeds the default name and nothing else — never load-bearing, never a trust or routing input, and the admin's chosen display name always wins over it. Empty is allowed.
HelloResponse — The server's reply to Hello.
Field Type # Description
accepted Accepted (oneof outcome) 1 admitted; the surface is active (the normal path)
mismatch VersionMismatch (oneof outcome) 2 last-resort reject: peer below the supported baseline
rejected Rejected (oneof outcome) 3 server is in closed mode; device_id not registered
Accepted — Signals the surface is active.
Field Type # Description
max_frame_bytes uint32 1 The largest frame (total_len) the core is willing to receive from this satellite; the satellite MUST cap its capture tails and inline blocks under it. 0 means the protocol default applies. The mirror of Hello.max_frame_bytes — each side bounds what it sends to the other.
max_concurrent_streams uint32 2 The largest number of capture streams the satellite may have open toward the core at once. The mirror of Hello.max_concurrent_streams — each side bounds the streams the other may open against it; 0 means the protocol default applies.
Rejected — Sent when the server is in closed mode and the connecting device's device_id is not registered.
Field Type # Description
reason RejectedReason 1
enum RejectedReason
Value # Description
REJECTED_REASON_UNSPECIFIED 0
REJECTED_CLOSED 1 server is in closed mode; only registered devices are admitted
VersionMismatch — The last-resort rejection — sent only when a peer is below the baseline the core still speaks.
Field Type # Description
server_wire_version uint32 1 the floor the core speaks
server_version string 2 human-readable server version label
enum Capability — The closed, typed set of operations the core may initiate against a device.
Value # Description
CAPABILITY_UNSPECIFIED 0 unset
CAPABILITY_MIC 1 core-initiated capture (e.g. enrollment)
CAPABILITY_SPEAKER 2 finite TTS render (drains and ends)
CAPABILITY_MEDIA_SINK 3 long-lived live A/V stream
CAPABILITY_TEXT_DISPLAY 4 a text surface, incl. chat/text bridges
CAPABILITY_IMAGE_DISPLAY 5 still image
CAPABILITY_HTML_DISPLAY 6 sandboxed HTML
CAPABILITY_TOOL_ACTIVITY 7 structured tool-call observation
CAPABILITY_IMAGE_CAPTURE 8 on-demand still-frame source (camera/screen)
CapabilityDescriptor — Declares one capability with its exact, typed parameters.
Field Type # Description
kind Capability 1 which capability this declares
mic MicParams (oneof params) 2 params when kind = CAPABILITY_MIC
speaker SpeakerParams (oneof params) 3 params when kind = CAPABILITY_SPEAKER
media_sink MediaSinkParams (oneof params) 4 params when kind = CAPABILITY_MEDIA_SINK
image ImageDisplayParams (oneof params) 6 params when kind = CAPABILITY_IMAGE_DISPLAY
html HtmlDisplayParams (oneof params) 7 params when kind = CAPABILITY_HTML_DISPLAY
image_capture ImageCaptureParams (oneof params) 8 params when kind = CAPABILITY_IMAGE_CAPTURE
MicParams
Field Type # Description
rate uint32 1 exact sample rate in Hz
channels uint32 2 channel count
width uint32 3 bytes per sample (2 = 16-bit)
SpeakerParams
Field Type # Description
rate uint32 1 0 = "your native rate, no resample"
channels uint32 2 channel count
width uint32 3 bytes per sample (2 = 16-bit)
buffer_ms uint32 4 Playout buffer depth in milliseconds the device can absorb. The core uses it to pace a live render over a reliable transport so media does not delay control; 0 lets the core choose. See the spec, Delivery classes.
MediaSinkParams
Field Type # Description
accepts repeated string 1 MIME types / codecs the sink accepts
rate uint32 2 exact sample rate in Hz
channels uint32 3 channel count
width uint32 4 bytes per sample (2 = 16-bit)
buffer_ms uint32 5 Playout buffer depth in milliseconds (see SpeakerParams.buffer_ms).
ImageDisplayParams
Field Type # Description
accepts repeated string 1 image MIME types the display decodes
width uint32 2 panel width in pixels
height uint32 3 panel height in pixels
HtmlDisplayParams — Declares the viewport of a sandboxed HTML surface, so the core can author markup at the device's render size — symmetric with ImageDisplayParams.
Field Type # Description
width uint32 1 viewport width in pixels
height uint32 2 viewport height in pixels
ImageCaptureParams — Declares which still-frame sources a device can produce on demand.
Field Type # Description
kinds repeated ImageSourceKind 1 the still-frame source kinds this device offers
enum ImageSourceKind — The modality of a still-frame source.
Value # Description
IMAGE_SOURCE_KIND_UNSPECIFIED 0 unset
WEBCAM 1 a camera pointed at the world or user
SCREEN 2 a screen / display grab
ToolDescriptor — The entire wire surface of a satellite-hosted tool.
Field Type # Description
name string 1 unique tool name the core calls
description string 2 what the tool does (model-facing)
input_schema string 3 JSON Schema, as a string
EventDescriptor — Declares a signal the device can emit — the inverse of a tool.
Field Type # Description
id string 1 stable, device-defined: "button.a", "motion"
metadata_schema string 2 optional JSON Schema for the payload
SettingDescriptor — Declares one device setting.
Field Type # Description
key string 1 stable setting key
label string 2 display label for the management UI
type SettingType 3 value type, for UI rendering
default_value SettingValue 4 value before the user changes it
options repeated EnumOption 5 choices for SETTING_ENUM; empty otherwise
EnumOption — One choice of a SETTING_ENUM: the value stored on the wire (string_value) plus the label the management UI shows for it.
Field Type # Description
value string 1 the enum choice, as carried in SettingValue.string_value
label string 2 display label for the management UI
enum SettingType
Value # Description
SETTING_TYPE_UNSPECIFIED 0 unset
SETTING_BOOL 1 boolean toggle
SETTING_NUMBER 2 numeric value
SETTING_STRING 3 free-form string
SETTING_ENUM 4 one of a fixed set of choices
SettingValue
Field Type # Description
bool_value bool (oneof value) 1 boolean value
number_value double (oneof value) 2 numeric value
string_value string (oneof value) 3 string value (also carries an enum choice)

content.proto

ContentBlock — One discrete, self-contained piece of content.
Field Type # Description
text string (oneof block) 1 Plain text, or JSON encoded as a string. A model reads both natively, so there is no separate structured-data variant.
media MediaBlock (oneof block) 2 Binary media, tagged by its MIME type and carried inline.
MediaBlock — Binary media, tagged by MIME type.
Field Type # Description
mime string 1 "image/png", "audio/opus", "video/h264", "application/pdf", …
data bytes 2 The media bytes, carried inline (no base64, no out-of-band tail). Empty when upload is set.
upload uint32 6 Envelope id of the completed UploadOpen stream carrying the bytes; 0 when data is inline. Satellite→core only.
width uint32 3 intrinsic width in px (image, video)
height uint32 4 intrinsic height in px (image, video)
duration_ms uint32 5 Optional duration in milliseconds (audio, video).

operations.proto

Ping — Carries liveness and a clock-offset round.
Field Type # Description
t1 uint64 1 requester send time, µs
PingResponse
Field Type # Description
t1 uint64 1 echoed requester send time (t1), µs
t2 uint64 2 responder receive time (t2), µs
t3 uint64 3 responder send time (t3), µs; t4 is recorded requester-local
Error — A typed failure correlated by the envelope id.
Field Type # Description
code ErrorCode 1 machine-readable failure code
message string 2 human-readable detail, for logs not end users
data bytes 3 optional, op-specific
enum ErrorCode
Value # Description
ERROR_CODE_UNSPECIFIED 0 unset (proto3 default); never sent deliberately
PARSE_ERROR 1 the frame could not be decoded
INVALID_REQUEST 2 well-formed frame, invalid for the current state
INVALID_PARAMS 3 a field value is out of range or unsupported
UNKNOWN_STREAM 4 id does not match an open stream
STREAM_CLOSED 5 the stream already ended or was cancelled
CAPABILITY_MISSING 6 op requires a capability the device never advertised
UNAVAILABLE 7 capability advertised but transiently unavailable
CANCELLED 8 the request was cancelled (e.g. a capture aborted)
INTERNAL_ERROR 9 unexpected failure on the responding side
FRAME_TOO_LARGE 10 frame exceeds the receiver's negotiated max_frame_bytes
TOO_MANY_STREAMS 11 opening a stream would exceed the peer's advertised max_concurrent_streams
PipelineState — The core pushes its current turn phase to the satellite as a pure UI signal.
Field Type # Description
phase Phase 1
error_code ErrorCode 2 Set only when phase == ERROR: the machine-readable reason (e.g. UNAVAILABLE = pipeline not ready). UNSPECIFIED otherwise. Enum-only — no human string: the detail is log-only and a string would force a nanopb max-size option.
Paired — Sent by the core to the satellite when an admin manually approves the device in the management UI (identifying it by its key fingerprint and display name).
Field Type # Description
Stop — The satellite's stateless turn-abort: "cancel whatever turn you are running for me, right now." It carries no stream id (envelope id 0) and depends on no open stream — that is the whole point.
Field Type # Description
StreamChunk — One frame of a stream.
Field Type # Description
seq uint32 1 monotonic frame index within the stream
sentence_seq uint32 2 optional, for karaoke sync
StreamEnd — Closes a stream gracefully — finalize and process.
Field Type # Description
reason StreamReason 1 why it ended
StreamCancel — Aborts a stream — discard on both sides.
Field Type # Description
reason StreamReason 1 why it was aborted
enum StreamReason
Value # Description
STREAM_REASON_UNSPECIFIED 0 unset
END_DETECTED 1 server's endpoint detector saw end of input (closes a capture)
PTT_RELEASE 2 push-to-talk button released
BARGE_IN 3 user spoke over playback (cancels a render)
COMPLETE 4 the producer finished normally
ERROR 5 the producer failed mid-stream
CaptureOpen — Opens an input stream.
Field Type # Description
kind CaptureKind 1 audio or video
trigger optional CaptureTrigger 2 Present = a discrete input the core processes and replies to. Absent = ambient (a continuous stream, not processed as a discrete input).
mode CaptureMode 3 live or deferred (store-and-forward)
encoding CaptureEncoding 4 codec of the chunk payloads
wake_score float 5 Only meaningful when trigger = WAKEWORD: the wake confidence, for server-side arbitration across satellites.
wake_lead_samples optional uint32 9 Only meaningful when trigger = WAKEWORD: the number of leading 16 kHz mono samples of this stream that precede the command — the preroll + wake word the satellite's own detector already located. The command onset is at this sample offset, so the server treats the capture as onset-confirmed immediately (skipping its VAD onset hysteresis) instead of re-deriving a boundary the client already knows. A hint, not a contract: the VAD onset path stays the backstop, so a wrong/absent value cannot wedge the turn. Absent/0 = no stamp (server derives onset from the audio as before).
from optional string 6 Optional raw speaker hint, passed through verbatim (see the spec, Speaker Identity).
correlation_ref optional string 7 Correlation handle tying this capture to the request that asked for it. Set by echoing the answered RequestCapture.correlation_ref when this CaptureOpen answers a RequestCapture (LIVE or DEFERRED), so the core matches the stream to its request and can route a decline; empty for a satellite-initiated capture (wakeword / PTT) that no RequestCapture prompted. For a DEFERRED upload it doubles as the resumable upload handle: it survives a reconnect (the envelope id resets), so the device reopens the DEFERRED CaptureOpen with the same correlation_ref and the core maps it to the partial state it holds and dedups frames around the last CaptureAck. A LIVE capture is stale on reconnect and dies with its connection.
lane string 8 Opaque room-grouping key owned by the satellite, set ONLY when the capture does not belong to a private 1:1 (a group chat / shared room); empty for a private capture. The core keys conversation history on it and treats a set lane as shared context (no private per-speaker memory). Same routing role as TextInput.lane; see the spec, Routing (from / to / lane).
enum CaptureKind
Value # Description
CAPTURE_KIND_UNSPECIFIED 0 unset
AUDIO 1 microphone / audio capture
VIDEO 2 camera / video capture
enum CaptureTrigger
Value # Description
CAPTURE_TRIGGER_UNSPECIFIED 0 unset
WAKEWORD 1 started by an on-device wakeword
PTT 2 started by a push-to-talk button
MANUAL 3 started by an explicit user action
enum CaptureMode
Value # Description
CAPTURE_MODE_UNSPECIFIED 0 unset
LIVE 1 real-time stream; chunks are LOSSY_OK
DEFERRED 2 store-and-forward; chunks are RELIABLE and carry capture_ts
enum CaptureEncoding
Value # Description
CAPTURE_ENCODING_UNSPECIFIED 0 unset
PCM_S16LE 1 raw 16-bit little-endian PCM
OPUS 2 Opus-compressed audio
CaptureAck — A cumulative mid-stream acknowledgement, sent only for a DEFERRED store-and-forward upload: the client may drop its buffer up to through_seq and resume without duplication after a disconnect.
Field Type # Description
through_seq uint32 1 all frames up to and including this seq are stored
UploadOpen — Opens a satellite→core binary upload stream — the input-direction mirror of ImageRenderOpen.
Field Type # Description
mime string 1 payload MIME, e.g. "image/jpeg"
total_bytes uint64 2 exact payload size; the core sizes its buffer and
TextInput — A discrete text input (chat satellites).
Field Type # Description
text string 1 the user's message
lang string 2 BCP-47 language tag, if known
from optional string 3 optional speaker hint (see Speaker identity)
images repeated MediaBlock 4 optional inline images accompanying the text (chat satellites forwarding a photo).
lane string 5 Opaque room-grouping key owned by the satellite, set ONLY when the message does not belong to a private 1:1 (a group chat / shared room); empty for a DM. The core keys conversation history on it and treats a set lane as shared context (no private per-speaker memory recall — a shared room must not leak one person's private facts). from stays the per-message sender either way. See the spec, Routing (from / to / lane).
Event — A device-emitted signal.
Field Type # Description
id string 1 stable device-defined signal id (e.g. "button.a")
metadata bytes 2 optional JSON payload (per the EventDescriptor schema)
from optional string 3 optional speaker hint (see Speaker identity)
LinkIdentity — A satellite-initiated control-plane request that binds one of the satellite's external identities to an aleph user.
Field Type # Description
pin string 1 the user's personal pairing PIN, entered in the chat
external_id string 2 the opaque identity to bind (e.g. "telegram:12345")
LinkIdentityResult — The terminal of a LinkIdentity request, on the same id.
Field Type # Description
ok bool 1 true when the PIN matched and the bind succeeded
user_name string 2 the linked user's display name (on ok), for the chat confirmation
error string 3 a short reason (on failure), e.g. "invalid PIN"
RequestCapture — Re-activates a client to produce input: the client answers with a CaptureOpen and streams up.
Field Type # Description
kind CaptureKind 1 audio or video to request
prompt string 2 optional, e.g. an enrollment sentence
mode CaptureMode 3 live or deferred
correlation_ref string 4 optional
image_source optional ImageSourceKind 5 Set for a still-frame image capture (kind = VIDEO): the device grabs one frame of this source and answers with a Response carrying the frame as an inline MediaBlock on this request's id — a discrete request/response, not a capture stream. Absent for an audio/live capture, where the device answers by opening a CaptureOpen and streaming up.
MediaRenderOpen — Opens a finite or live render stream to a speaker or media sink.
Field Type # Description
surface Capability 1 The render target, one of the device's declared capabilities: CAPABILITY_SPEAKER (finite TTS) or CAPABILITY_MEDIA_SINK (live A/V).
format string 2 The MIME type / codec the core will send chunks in, e.g. "audio/opus" for a SPEAKER or "video/h264" for a MEDIA_SINK. A string (not the audio-only CaptureEncoding enum) so a live A/V open can name any codec the sink advertised in MediaSinkParams.accepts. A muxed container (e.g. "video/webm") carries audio and video together in one stream.
to optional string 3 Routing on a multi-conversation bridge: the core stamps the driving turn's delivery target here so a satellite multiplexing many chats sends this stream to the right one. to is the person (echo of the input from, same opaque ref type); lane is the room, set for a group. The satellite delivers to coalesce(lane, to). Both empty on a single-conversation device.
lane string 4
TextRenderOpen — Opens a reliable text stream into the text surface.
Field Type # Description
role TextRole 1 which text stream this is
to optional string 2 Routing on a multi-conversation bridge, same semantics as MediaRenderOpen.to / .lane: deliver to coalesce(lane, to).
lane string 3
enum TextRole
Value # Description
TEXT_ROLE_UNSPECIFIED 0 unset
TRANSCRIPT 1 live transcript of what the user says (replace-style)
ASSISTANT 2 the assistant's reply tokens (append)
KARAOKE 3 which sentence is being spoken (synced to playback)
CAPTION 4 description of a pushed image (alt-text), so it reaches every text surface
ImageRenderOpen — Streams a still image into an IMAGE_DISPLAY surface when it is too large to ride one frame as an inline Render.
Field Type # Description
mime string 1 image MIME, e.g. "image/png", "image/jpeg"
width uint32 2 intrinsic image width in px
height uint32 3 intrinsic image height in px
total_bytes uint64 4 full encoded size, so the device sizes its buffer up front
to optional string 5 person routing handle (echo of input from); deliver to coalesce(lane, to)
lane string 6 room routing handle, set for a group; see MediaRenderOpen.lane
Render — A one-shot reliable push to a display surface.
Field Type # Description
surface Capability 1 The display target, one of the device's declared capabilities: CAPABILITY_IMAGE_DISPLAY, CAPABILITY_HTML_DISPLAY, or CAPABILITY_TEXT_DISPLAY.
content ContentBlock 2 the block to display
to optional string 3 person routing handle (echo of input from); deliver to coalesce(lane, to)
lane string 4 room routing handle, set for a group; see MediaRenderOpen.lane
MediaControl — Steers the client-side jitter/playout buffer of a MEDIA_SINK stream, and sets its playout volume.
Field Type # Description
op MediaOp 1 pause, resume, flush, or set-volume — steers the
volume_pct uint32 2 0–100, only meaningful when op = SET_VOLUME;
enum MediaOp
Value # Description
MEDIA_OP_UNSPECIFIED 0 unset
PAUSE 1 hold playout, keep the buffer
RESUME 2 resume playout
FLUSH 3 drop the buffered audio now
SET_VOLUME 4 set playout volume to volume_pct (user volume + ducking)
Presence — Optional multi-user ambient context (who is in the channel), for model context only.
Field Type # Description
present repeated string 1 opaque from-refs of who is currently present
SettingsWrite — Tells a device to apply a setting; the device acts and replies with a SettingState on the id.
Field Type # Description
key string 1 which setting to change
value SettingValue (oneof action) 2 set the setting to this value
reset bool (oneof action) 3 or reset it to its default
SettingState — Reports a device setting value.
Field Type # Description
key string 1 which setting this reports
value SettingValue 2 its current value
Invoke — Calls a satellite-hosted tool.
Field Type # Description
name string 1 the tool to call
args bytes 2 JSON arguments
observe bool 3 true = informational copy, do not execute
Response — The terminal of a CaptureOpen, correlated by its id.
Field Type # Description
content repeated ContentBlock 1 trailing result blocks (may be empty)
ToolResult — The result of an Invoke, correlated by id.
Field Type # Description
content repeated ContentBlock 1 the tool's result blocks
observe bool 2 mirrors the Invoke observe flag
PlaybackDone — The terminal of a MediaRenderOpen, correlated by id.
Field Type # Description
frames uint32 1 Sample-frames actually played: the count of samples PER CHANNEL (one frame = one sample across all channels), not total samples and not bytes. For 16 kHz mono, frames = 9600 is 0.6 s.
underruns uint32 2 buffer underruns during playback (device-local; the core cannot see these otherwise)

References

Normative References

[RFC2119]
S. Bradner. Key words for use in RFCs to Indicate Requirement Levels. March 1997. Best Current Practice. URL: https://datatracker.ietf.org/doc/html/rfc2119
[RFC8174]
B. Leiba. Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words. May 2017. Best Current Practice. URL: https://www.rfc-editor.org/info/rfc8174/

Non-Normative References

[ESP32-P4]
ESP32-P4 product page (hardware H.264 video encoder). URL: https://www.espressif.com/en/products/socs/esp32-p4
[ESP32-S3-DATASHEET]
ESP32-S3 Series Datasheet (§ SHA Accelerator; 512 KB SRAM). URL: https://www.espressif.com/sites/default/files/documentation/esp32-s3_datasheet_en.pdf
[ESPHOME-API]
ESPHome Native API Protocol. URL: https://developers.esphome.io/architecture/api/protocol_details/
[GGWAVE]
Georgi Gerganov. ggwave — Tiny data-over-sound library. URL: https://github.com/ggerganov/ggwave
[GRPC]
gRPC: A high performance, open source universal RPC framework. URL: https://grpc.io/
[GRPC-HTTP2]
gRPC over HTTP/2. URL: https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
[HTML]
HTML Standard — The iframe element (sandbox attribute). URL: https://html.spec.whatwg.org/multipage/iframe-embed-object.html#attr-iframe-sandbox
[I2S-ESP-IDF]
Inter-IC Sound (I2S) — ESP-IDF Programming Guide. URL: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/i2s.html
[LIVEKIT]
LiveKit. URL: https://github.com/livekit/livekit
[LIVEKIT-ESP32]
LiveKit client SDK for ESP32 (Developer Preview). URL: https://github.com/livekit/client-sdk-esp32
[NOISE]
Trevor Perrin. The Noise Protocol Framework. 2018-07-11. Revision 34 (latest). URL: https://noiseprotocol.org/noise.html
[PROTOBUF]
Google. Protocol Buffers — Encoding. URL: https://protobuf.dev/programming-guides/encoding/
[RFC6716]
JM. Valin; K. Vos; T. Terriberry. Definition of the Opus Audio Codec. September 2012. URL: https://www.rfc-editor.org/rfc/rfc6716
[RFC7540]
M. Belshe; R. Peon; M. Thomson. Hypertext Transfer Protocol Version 2 (HTTP/2). May 2015. Obsoleted by RFC 9113. URL: https://www.rfc-editor.org/rfc/rfc7540
[RFC7541]
R. Peon; H. Ruellan. HPACK: Header Compression for HTTP/2. May 2015. URL: https://www.rfc-editor.org/rfc/rfc7541
[RFC7748]
A. Langley; M. Hamburg; S. Turner. Elliptic Curves for Security (Curve25519, Curve448). January 2016. URL: https://www.rfc-editor.org/rfc/rfc7748
[RFC8439]
Y. Nir; A. Langley. ChaCha20 and Poly1305 for IETF Protocols. June 2018. URL: https://www.rfc-editor.org/rfc/rfc8439
[RFC9000]
J. Iyengar; M. Thomson. QUIC: A UDP-Based Multiplexed and Secure Transport. May 2021. URL: https://www.rfc-editor.org/rfc/rfc9000
[RFC9001]
M. Thomson; S. Turner. Using TLS to Secure QUIC. May 2021. URL: https://www.rfc-editor.org/rfc/rfc9001
[RFC9113]
M. Thomson; C. Benfield. HTTP/2. June 2022. Internet Standard (obsoletes RFC 7540, RFC 8740). URL: https://www.rfc-editor.org/rfc/rfc9113
[RFC9221]
T. Pauly; E. Kinnear; D. Schinazi. An Unreliable Datagram Extension to QUIC. March 2022. URL: https://www.rfc-editor.org/rfc/rfc9221
[WEBRTC]
WebRTC: Real-Time Communication in Browsers. URL: https://www.w3.org/TR/webrtc/
[WYOMING]
Wyoming Protocol. URL: https://github.com/OHF-Voice/wyoming