awob: protocol + listener author guide
For end-user CLI / config see Usage; for theme authoring see Themes. This document is for people writing new event-source listeners or tools that talk to the daemon directly.
Transport
- Unix stream socket at
$XDG_RUNTIME_DIR/awob.sock(override via--socket <path>on the daemon,awob.toml'ssocketkey, orAWOB_SOCKETenv var on listeners and the CLI). An explicit--sockettakes precedence overAWOB_SOCKET; an empty or unset variable uses the default. - JSON-lines. One
RequestJSON object per line (\n-terminated) in; oneResponseJSON object per line out. UTF-8. - Synchronous, single-shot per line. Send a request, read a response, repeat. No multiplexing, no streaming responses.
serde-tagged enum: every JSON object has a"type"discriminator.
The socket's parent must be a real directory owned by the daemon's user, with
no group or other permissions (normally mode 0700). Missing parent directories
are created with mode 0700; existing directories are never chmodded. This also
applies to custom socket paths: use a private directory, not /tmp/awob.sock.
The socket is published with mode 0600.
Startup refuses symlinks, ordinary files and sockets owned by another user at the chosen path. It only replaces a stale socket owned by the daemon's user. Shutdown removes the socket it created and leaves replacement entries alone.
Connection lifetime is up to the client. Listeners typically hold a long-lived connection; CLI invocations connect, send, disconnect.
Protocol version
PROTOCOL_VERSION is currently 1. Clients are expected to send a
Hello first (the daemon checks the version and returns
Response::Hello { protocol, daemon_version } or
Response::Error { … } on mismatch). The awob-client crate does
this automatically on Client::connect().
Requests
| Variant | Purpose |
|---|---|
Hello { protocol: u32 } | Handshake. |
Send(SendPayload) | Trigger an OSD. Hot path. |
Query { source: Option<String> } | List history entries (optionally filtered by source). |
SetTheme { name: String, persist: bool } | Switch active theme. With persist, also rewrites awob.toml. |
Reload | Reread current theme files. |
Version | Get daemon version + protocol number. |
The daemon admits up to 64 active client connections. Further connections receive
an Error saying daemon busy: active connection limit reached and are closed.
Idle persistent connections stay open and count toward the limit.
Once a client starts a request, it has two seconds to finish the existing maximum 64 KiB JSON line. Each response also has a two-second total write deadline. Sending occasional bytes does not extend these deadlines. A timeout closes the connection; theme loading and other request processing are separate from the I/O budgets. Clients must not replay requests whose outcome is unknown after a lost connection. The Rust SDK accepts response lines up to 32 MiB, including the newline. This leaves room for escaped history-query results. Once connected, writing a request and reading its response share a two-second I/O budget; slowly streamed bytes do not restart it. A safe reconnect retry uses the remaining budget.
Oversized, malformed, or timed-out responses close the transport. Requests are never replayed after any request bytes have been written, because the daemon may already have handled them. A subsequent listener event reconnects normally.
Responses
| Variant | Purpose |
|---|---|
Ok | Generic success. |
Error { message: String } | Error string. |
Hello { protocol: u32, daemon_version: String } | Reply to Hello. |
Query { entries: Vec<HistoryEntry> } | Reply to Query. |
Version { daemon_version: String, protocol: u32 } | Reply to Version. |
HistoryEntry: source, event, last_value, last_max,
age_seconds, listener_id. One entry per (source, event) pair.
The surface command queue holds up to 64 commands. When full, Send, SetTheme,
Reload, and SetForcePalette return an Error with surface busy: command queue is full; retry later. A disconnected rendering thread returns surface unavailable: rendering thread disconnected. Queue-rejected requests do not update
history, change the active theme or palette, or persist a theme choice. Clients
may retry an explicit busy response after a short delay. Headless mode has no
surface queue.
Accepted surface commands retain their order. The existing single-slot pending OSD behavior described below still applies once commands reach the renderer. Automatic theme reload retries through its debounce window when the queue is full, keeping the previous theme active until the replacement is accepted.
SendPayload reference
This is the hot-path message. Field-by-field:
| Field | Type | Default | Meaning |
|---|---|---|---|
event | String | required | Free-form event name ("volume", "brightness", "battery", "mic", "caps", …). The daemon doesn't check it against a list — it's whatever your listener wants. Influences default icon/label via the expression language's icon($event) / label($event) builtins. |
value | f64 | required | Current measurement. Units match max. |
max | f64 | 100.0 | Upper bound. The bar renders (value - min) / (max - min). |
listener_id | Option<String> | None | Stable identity for the listener type (e.g. "awob-listener-pipewire"). The daemon emits a duplicate-listener warning if it sees two distinct sources sharing one listener_id. CLI invocations leave this unset. |
source | Option<String> | None | Per-instance identity (e.g. "pipewire-7a3f"). Together with event, forms the history key. Sends without source get no history → $lastValue is Null. |
style | Option<String> | None | Named style block to apply ("low", "normal", "warn", "critical", "muted", or any custom name). Defaults to "normal" at render time. |
accent | Option<String> | None | One-off CSS-syntax colour override applied after style merge. |
app | Option<String> | None | Free-form label. Available as $app in the theme. |
icon | Option<String> | None | Icon name / path / data: URI. Available as $icon. |
timeout_ms | Option<u32> | None | One-shot override for surface.show duration. |
preempt | bool | false | See Preempt semantics. |
JSON shape (with all fields populated):
{
"type": "send",
"event": "volume",
"value": 0.7,
"max": 1.0,
"listener_id": "awob-listener-pipewire",
"source": "pipewire-7a3f",
"style": "normal",
"accent": null,
"app": "Speakers",
"icon": "audio-volume-high",
"timeout_ms": null,
"preempt": true
}
Preempt semantics
preempt controls how a send interacts with an OSD that's already
on screen:
- Same
(source, event)as the visible OSD → continuity update, regardless ofpreempt. Bar value tweens to the new target; alpha fade doesn't restart; show timer resets. - Different
(source, event),preempt: true→ hot-swap. The visible OSD is replaced immediately. Bar value continues from its current interpolated position to the new target so the swap is smooth. - Different
(source, event),preempt: false→ queued in a single-slot, last-write-wins buffer. When the active OSD reachesPhase::Done(after fade-out), the queued send drains as a fresh cycle.
When to set preempt: true:
- Volume keys (the user just pressed a button; show it now).
- Brightness keys.
- Mic mute.
- Anything user-initiated where queueing behind a battery bar would feel broken.
When to leave preempt: false:
- Battery state changes.
- Network connection events.
- Any ambient/background notification that shouldn't preempt whatever the user is currently doing.
History keying
The daemon's history map is keyed by (source, event). Distinct
events on the same source — e.g. volume then mute on a single
PipeWire node — don't cross-contaminate $lastValue. A listener
publishing both metrics for one source uses the same source value
with different event values:
{ "type": "send", "event": "volume", "value": 0.6, "source": "speaker", "preempt": true }
{ "type": "send", "event": "mute", "value": 1.0, "source": "speaker", "preempt": true, "icon": "audio-volume-muted" }
Each gets its own history slot.
History expires 30 minutes after the last update. Expired entries are excluded
from queries and from $lastValue/$lastMax, including the first send after an
idle period.
The daemon retains at most 4,096 history entries and 4 MiB of identifier data
(source, event, and listener_id, counted per entry). Indexes share these
strings; shared identifiers are still charged once per entry. When either limit
is reached, the least recently updated entries are evicted first. Entries with
the same update time are evicted in lexical (source, event) order. Reading or
querying an entry does not refresh it.
An event whose identifiers exceed the entire 4 MiB budget is displayed but not
remembered, and any previous history for that key is removed. Identifiers are
never truncated. After expiry or eviction, the next event for that key has no
previous value: $lastValue and $lastMax are Null, and value interpolation
starts from the new value. History eviction also removes the entry's listener
membership; another event from the same listener and source keeps that membership
active.
Building a listener
Long-running event source. Pattern:
- Connect to the daemon socket (or accept
AWOB_SOCKETenv var; the supervisor sets it). - Subscribe to your upstream (D-Bus signal, sysfs notify, FIFO read, PipeWire registry, …).
- On each upstream event, build a
SendPayloadand write a JSON line to the socket. - Reconnect on socket loss; the daemon's supervisor will respawn you on crash.
The simplest path is to depend on awob-client:
[dependencies]
awob-client = "0.0"
tracing = "0.1"
use awob_client::{ReconnectingClient, Send};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let socket = std::env::var_os("AWOB_SOCKET").map(std::path::PathBuf::from);
let mut client = ReconnectingClient::new(socket);
loop {
let value = read_upstream()?; // your event source
let payload = Send::new("volume", value)
.max(1.0)
.source("my-source-id")
.listener_id("awob-listener-mything")
.icon("audio-volume-high")
.app("My Source")
.preempt(true)
.auto_listener_id() // basename(argv[0]) if not set
.build();
if let Err(error) = client.send(payload) {
tracing::warn!("send: {error}");
}
}
}
ReconnectingClient opens a connection on the first event and reuses it. On a
write failure it reconnects and retries once only if zero request bytes were
written. Partial writes, timeouts and lost acknowledgements are returned to the
caller without replaying the event; the next event opens a new connection. This
avoids duplicate overlays when the daemon received a send but its response was
lost. The PipeWire, battery, backlight, keyboard-backlight and power-profile
listeners use this helper and log send failures.
The Send builder validates field combinations and sets sensible
defaults. auto_listener_id() is a convenience that fills
listener_id with the basename of the running binary if you didn't
set it explicitly — used by all the bundled listeners so the daemon
can warn on duplicate processes.
Choosing identifiers
listener_idis per-binary or per-binary-variant. PipeWire's listener publishesawob-listener-pipewire-speakerandawob-listener-pipewire-micfrom one process — they're "different listener types" semantically.sourceis per-physical-source. One sink in PipeWire = one source. Use a stable identifier (PipeWire object id, sysfs device name likeBAT0, D-Bus object path); avoid PIDs — they change on respawn and break history continuity.
The daemon flags as duplicate when two distinct sources share one
listener_id. That signals two processes of the same listener
running concurrently — almost always a misconfiguration.
Talking directly without awob-client
The wire format is small enough to drive from any language:
printf '%s\n' '{"type":"hello","protocol":1}' \
'{"type":"send","event":"volume","value":50,"max":100,"preempt":true}' \
| nc -U "$XDG_RUNTIME_DIR/awob.sock"
Replace nc -U with whatever Unix-socket plumbing your runtime
provides. Each output line is a Response JSON object.
Auto-discovery + supervisor
If your listener's binary lands on $PATH or in the daemon's own
directory, and you give it a stable name, you can register it for
automatic spawning:
-
Add an entry to
KNOWN_LISTENERSincrates/awob-daemon/src/known_listeners.rs:pub const KNOWN_LISTENERS: &[KnownListener] = &[…,KnownListener { name: "mything", binary: "awob-listener-mything" },]; -
Make sure the binary works with no arguments. Listeners that need flags don't belong in the registry — they require explicit
[[listeners]]blocks where the user provides args.
The daemon will then auto-spawn and supervise it on startup whenever it's installed.
Theme updates
Theme changes and hot reloads prepare the replacement while sends and queries continue using the current theme. After preparation succeeds, the daemon queues the replacement and publishes it atomically. A load failure or rejected surface command leaves the previous theme and palette active.
Theme changes are serialized, including persistence, so an earlier save cannot overwrite a later selection. Persistence runs after the new theme becomes active; if saving fails, the error states that the theme changed in memory. Theme listing uses a snapshot of the active name and does not block sends while scanning files.
Theme persistence
SetTheme { name, persist: true } rewrites the theme key in
awob.toml using toml_edit. Comments, key order, and unrelated
tables ([supervisor], [[listeners]], …) are preserved. The
config path used for the rewrite is whichever was loaded at startup
(--config <path> or the XDG default at
$XDG_CONFIG_HOME/awob/awob.toml).
If no config path is in effect, SetTheme { persist: true } returns
an Error saying so — the in-memory theme still changes.
Cold-start fallback
If the configured theme can't be loaded at daemon start (missing
directory, parse error, etc.), the daemon warns to stderr and falls
back to the embedded default. This means awob always comes up with
something renderable, so the user can awob theme set <real> to
recover. There's no scenario where awob refuses to start because of
a bad theme.
Stability
PROTOCOL_VERSION = 1 is pre-1.0. The Request/Response shape is
likely stable (well-trafficked across all listeners + the CLI), but
adding fields to SendPayload is expected. Old clients omit
unknown fields → serde(default) makes them roundtrip safely. New
clients targeting old daemons should rely on Hello rejection
rather than feature-detection.