bread/Documentation.md
Breadway 6063eb901c
All checks were successful
dev release / build (push) Successful in 1m29s
State engine applies bread.hyprland.* and snapshots compositor topology
Match both legacy and namespaced Hyprland events so flipping
legacy_hyprland_event_names no longer freezes monitors/workspace/window.
On event-socket connect, query socket1 and emit bread.hyprland.snapshot
to populate RuntimeState (including workspaces). API 1.7.1.
Track AGENTS.md instead of gitignored CLAUDE.md.
2026-08-15 22:03:30 +08:00

1770 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Bread Documentation
## Contents
- [Overview](#overview)
- [API Stability & Versioning](#api-stability--versioning)
- [Getting started](#getting-started)
- [Your first module](#your-first-module)
- [Run, reload, and watch](#run-reload-and-watch)
- [Modules: install and manage](#modules-install-and-manage)
- [Capability-scoped modules](#capability-scoped-modules-since-v15)
- [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16)
- [Debugging tips](#debugging-tips)
- [Dictionary: Lua API](#dictionary-lua-api)
- [Workflows](#workflows-since-v12)
- [Widgets](#widgets-since-v13)
- [Bluetooth](#bluetooth)
- [Dictionary: Built-in modules](#dictionary-built-in-modules)
- [Dictionary: Event reference](#dictionary-event-reference)
- [Namespaces](#namespaces)
- [Integrating a bread* app](#integrating-a-bread-app)
- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)
- [Dictionary: IPC protocol](#dictionary-ipc-protocol)
## Overview
Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation.
- **Daemon** (`breadd`) — long-running Rust process; source of truth for runtime state
- **Lua runtime** — dedicated thread inside the daemon; automation logic lives here
- **CLI** (`bread`) — talks to the daemon over a Unix socket
Adapters currently supported: Hyprland compositor IPC, Linux udev/netlink, UPower/sysfs power, rtnetlink/sysfs network, BlueZ Bluetooth, shell precmd/preexec hooks (terminal), git hooks + a dirty-state poller, project-root filesystem watches, `systemd --user` unit state, Podman container events, and SSH/remote session detection. Sibling `bread*` applications (breadclip, breadpad, and others across the BOS ecosystem) integrate through the same pipeline under a reserved `bread.<app>.*` namespace — see [Namespaces](#namespaces).
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
## API Stability & Versioning
The Lua API surface, the IPC method set, the event-name vocabulary, and the runtime-state schema documented in this file are collectively **Bread Automation API v1**. This is what "locking in the schema" means operationally:
- **Additive-only within a major version.** New bindings, new events, new state fields, and new optional IPC params may be added in a minor release. Existing binding signatures, event names, event `data` shapes, state field meanings, and IPC method contracts do not change or disappear within v1.
- **Deprecation window.** Anything slated for removal is marked `Deprecated` in this file for at least one minor release cycle and continues to function until the next major version (v2).
- **Since markers.** Additions made after the v1.0 baseline are marked inline with `*Since: vX.Y*`. Anything documented in this file without a marker is part of the v1.0 baseline.
- **Version discovery.** The current API version is returned as `api_version` in the `health` IPC response (see [Dictionary: IPC protocol](#dictionary-ipc-protocol)), so a client — the CLI, a Lua module, or a sibling `bread*` app — can assert compatibility at connect time rather than discovering a mismatch mid-session.
This matters because the moment sibling apps and community modules depend on this vocabulary, it becomes a contract that can break people. Treat this file, not `README.md` or `AGENTS.md`, as the single source of truth — those files intentionally point back here rather than keeping their own copies, after a duplicated Lua API section in `README.md` was found to have already drifted from reality.
## Getting started
### 1) Create a minimal config
- Daemon config: `~/.config/bread/breadd.toml` (all values optional)
- Declarative rules (optional, no Lua required): `~/.config/bread/rules.toml`
- Lua entry point: `~/.config/bread/init.lua`
- Lua modules: `~/.config/bread/modules/`
### 2) The fast path: `rules.toml` *(Since: v1.5)*
For the common "when event X happens, do Y" case, you don't need Lua at
all. Create `~/.config/bread/rules.toml`:
```toml
[[rule]]
on = "device.dock.connected"
run = "~/.config/bread/scripts/dock-connected.sh"
[[rule]]
on = "power.ac.disconnected"
notify = "Unplugged"
[[rule]]
on = "device.keyboard.connected"
exec = "xset r rate 200 40"
```
Each `[[rule]]` needs exactly two things: an `on` (an event-name suffix —
`bread.` is implied, so `"device.dock.connected"` matches the real event
`bread.device.dock.connected`; wildcards `*`/`**`/`?` work the same way they
do in `bread.on()`) and exactly one action:
| Action | Meaning |
|--------|---------|
| `run = "<path>"` | Run exactly one script/program at that path. The path is tilde-expanded and quoted as a single unit for you, so spaces in it are safe — it will *not* be word-split into a command plus arguments. |
| `exec = "<command line>"` | Run a full shell command line via `bread.exec()`, exactly as if you'd typed it in a shell — quote/escape arguments yourself. |
| `notify = "<message>"` | Show a desktop notification with this text via `bread.notify()`. |
`rules.toml` is entirely optional and purely additive alongside
`init.lua` — both can coexist, rules load before user-defined modules, and
an absent file is not an error. A malformed rule (missing/empty `on`, or
zero/multiple action keys set) doesn't stop the rest of the file from
working: the other rules in the file still register, and the specific bad
rule shows up via `bread doctor` (see [Debugging tips](#debugging-tips))
the same way a broken Lua module's error would.
This covers the common cases directly. For fuzzier matching (substring
device-name matching, filtering by a list of monitors, etc.) or any logic
beyond "run this one action," reach for `bread.devices` /
`bread.monitors` or hand-written Lua in `init.lua` — see
[Dictionary: Built-in modules](#dictionary-built-in-modules) and the next
section.
### 3) Minimal `init.lua`
```lua
bread.on("bread.system.startup", function(event)
bread.profile.activate("default")
bread.log("bread started on " .. bread.machine.name())
end)
```
### 4) Start the daemon
```bash
systemctl --user start breadd
# Or directly:
breadd
```
### 5) Check that it's running
```bash
bread ping
bread doctor
```
## Your first module
Create a file at `~/.config/bread/modules/hello.lua`. It is discovered and loaded automatically after `init.lua`.
```lua
local M = bread.module({ name = "hello", version = "0.1.0" })
function M.on_load()
bread.log("hello from bread on " .. bread.machine.name())
bread.on("bread.device.*", function(event)
bread.log("device event: " .. event.event)
end)
end
return M
```
Key rules:
- Every module must call `bread.module` exactly once at the top level.
- Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload.
- Use `bread.log` early to verify handlers are firing.
A flat file like `modules/hello.lua` with no manifest gets full, unscoped
`bread.*` access — exactly what you see above, unchanged. That's fine for a
personal one-off. Once you install a module properly (`bread modules
install`), it's worth declaring what it actually uses — see
[Capability-scoped modules](#capability-scoped-modules-since-v15).
## Run, reload, and watch
```bash
# Hot-reload the Lua runtime after editing config
bread reload
# Watch for file changes and reload automatically
bread reload --watch
```
If any module fails to load, `bread reload` prints the error with a full Lua stack trace. The daemon stays running — fix the file and reload again.
## Modules: install and manage
Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle.
Modules install from a **local directory only**. They run with full
`bread.exec()` privileges and are not sandboxed; remote installation was
removed so that reviewing third-party code stays an explicit, manual step. To
use a module published on a git host, clone it yourself, review it, then
install from the checkout.
```bash
# Clone and review, then install from the local checkout
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi
bread modules install ~/src/bread-wifi
# List installed modules and their daemon status
bread modules list
# Show full manifest for one module (including its declared permissions)
bread modules info bread-wifi
# Get a suggested [[permissions]] block from a static scan of the module's
# Lua source — see "Capability-scoped modules" below
bread modules audit bread-wifi
# Remove a module
bread modules remove bread-wifi
bread modules remove bread-wifi --yes # skip confirmation
```
Each installed module has a `bread.module.toml` manifest:
```toml
name = "wifi"
version = "1.0.0"
description = "WiFi management for Bread"
author = "someuser"
source = "/home/you/src/bread-wifi"
installed_at = "2026-01-01T00:00:00Z"
[[permissions]]
type = "exec"
bin = "nmcli"
[[permissions]]
type = "notify"
```
`permissions` is optional *(Since: v1.5)*. Omitting it entirely — every
manifest written before v1.5, and any manifest an author just hasn't gotten
around to annotating — means the module runs exactly like it always has:
full, unscoped `bread.*` access. See the next section for what declaring it
actually buys you and the full permission taxonomy.
## Capability-scoped modules *(Since: v1.5)*
By default every third-party module gets the full `bread` table — the same
one built-in modules and `init.lua` see. `[[permissions]]` in
`bread.module.toml` narrows that: a module only sees the `bread.*` bindings
it was granted, plus a fixed **baseline** every module gets regardless.
Anything not granted is genuinely **absent**`bread.fs == nil`, not
`bread.fs.read()` throwing a permission error — so a module written
defensively (`if bread.fs then ... end`) degrades exactly the way it would
if, say, Bluetooth hardware weren't present.
*Since: v1.6* — declaring `[[permissions]]` at all (even an empty list)
also determines **where** the module runs: see [Out-of-process module
sandboxing](#out-of-process-module-sandboxing-since-v16) below. The
`bread` table shape described in this section is what such a module sees
either way; what changed is what backs it and what happens if the module
ignores it entirely and reaches for `os`/`io` directly.
### Baseline (always available, no manifest entry needed)
Event subscription and timers are how a module does anything at all, so
they're never gated: `bread.on`/`once`/`filter`/`off`/`emit`,
`bread.after`/`every`/`cancel`. Also baseline: `bread.json` (pure decode,
no I/O), `bread.module` (required just to register), `bread.log`/`warn`/
`error` (diagnostics), and the pure-Lua sugar built entirely on top of the
above — `bread.debounce`, `bread.spawn`/`wait`/`wait_any`/`wait_all`,
`bread.workflow.*`.
### Gated — requires a matching `[[permissions]]` entry
| `type` | Grants | Notes |
|--------|--------|-------|
| `state.read` | `bread.state.get`/`.monitors`/`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/`.profile` | Read-only snapshots of daemon state. `path` is an advisory scoping hint (e.g. `"monitors"`), not yet enforced per-call — see the note below. |
| `state.watch` | `bread.state.watch` | Split from `state.read`: a standing subscription is a more persistent capability than a one-off read. |
| `profile.activate` | `bread.profile.activate` | Switches the daemon's system-wide active profile — a real cross-module side effect. |
| `exec` | `bread.exec`, `bread.exec_capture` | Spawns an arbitrary shell command. `bin` is an advisory hint (e.g. `"hyprpaper"`). |
| `notify` | `bread.notify` | Desktop notifications. |
| `machine` | `bread.machine.name`/`.tags`/`.has_tag` | Reads hostname/tags, including an optional on-disk `sync.toml`. |
| `hyprland` | `bread.hyprland.*` | Compositor IPC — `dispatch`/`keyword`/`eval` control the session, `monitors`/`workspaces`/`clients`/`active_window`/`on_raw` observe it. Not split further; grant it for either. |
| `widget` | `bread.widget.register`/`.update`/`.remove`/`.list` | Registers UI in a sibling `bread*` app (breadbar). |
| `fs.read` | `bread.fs.read`/`.exists`/`.readlink`/`.expand` | Read-only filesystem access. `path` is an advisory scoping hint. |
| `fs.write` | `bread.fs.write` | Filesystem writes. Split from `fs.read` — a module that only reads shouldn't need to declare write access. |
| `bluetooth` | `bread.bluetooth.*` | BlueZ control — power/connect/disconnect/scan/devices. |
Example — a module that switches wallpaper via `hyprpaper` based on the
current monitor layout, and reads images from one directory:
```toml
[[permissions]]
type = "exec"
bin = "hyprpaper"
[[permissions]]
type = "state.read"
path = "monitors"
[[permissions]]
type = "fs.read"
path = "~/Wallpapers"
```
That module's `bread` table has `bread.exec`, `bread.state` (read
functions only — no `bread.state.watch`), and `bread.fs` (read functions
only — no `bread.fs.write`), plus the full baseline. `bread.hyprland`,
`bread.bluetooth`, `bread.notify`, `bread.machine`, and `bread.widget` are
all `nil`.
An explicit empty list (`permissions = []`) is a deliberate "baseline only"
declaration — different from omitting the key entirely. It scopes the
module down for real but is *not* flagged by `bread doctor`, since the
author made a conscious choice rather than just not knowing about this
feature yet.
### `path`/`bin` enforcement depends on where the module runs
This section describes the **in-process** scoping mechanism
(`build_scoped_env` in `breadd/src/lua/mod.rs`), which only ever gated
*presence* of a `bread.*` binding — the `path`/`bin` fields on each
permission were recorded in the manifest but never checked against the
actual arguments a module passed at runtime, and `os.execute`/`io.open`/
`debug.*` remained fully reachable from Lua's standard library regardless
of what a module's `bread` table contained. That's still exactly true for
a module with **no manifest at all** (the legacy/backward-compat path,
`ungated: true` in `modules.list`) — see [Out-of-process module
sandboxing](#out-of-process-module-sandboxing-since-v16) below.
*Since: v1.6* — a module that declares `[[permissions]]` (any, including
an explicit empty list) no longer runs in-process at all. It's spawned as
a separate, OS-sandboxed `bread-module-host` process instead, and for that
process `path`/`bin` *are* enforced for real, at the kernel level, via a
Landlock ruleset — independent of whether the module even uses the
documented `bread.*` API or goes straight for `os.execute`/`io.open`. See
the linked section for exactly what's covered and what's still deferred.
### `require("bread.devices")` still works from a scoped module
Builtin library modules (`bread.devices`, `bread.monitors`, `bread.workspaces`,
`bread.binds`) always load with the full ambient `bread` table — they're
never subject to manifest-based scoping, regardless of what any third-party
module that `require`s them declares. `require("bread.devices")` resolves
via Lua's real `package.loaded` table (already populated by the time any
third-party module loads, since builtins load first) — a real global,
reachable from a scoped module through a metatable fallback to the true
globals for everything that isn't `bread` itself (`pairs`, `string`,
`table`, `require`, `package`, ...). The returned module's own functions
(`devices.on()` etc.) were defined while `bread.devices` loaded unscoped,
so they close over the *real* `bread` table as a Lua upvalue — closures
capture their defining environment lexically, not the caller's — which is
exactly why calling `devices.on(...)` from inside a scoped module works
with no special-casing needed.
### `bread modules audit <name>`
Best-effort static scan of an installed module's `.lua` files (its entry
file plus any others in the same directory) for `bread.*` call-site
patterns, printing a suggested `[[permissions]]` block to review and paste
into `bread.module.toml`:
```bash
bread modules audit bread-wifi
```
This is a text scan, not a Lua parser — false positives (suggesting a
permission the module doesn't strictly need) are expected and fine; false
negatives on a plain `bread.exec("...")`-style call site should be rare,
but dynamic/computed call sites (`bread[method_name](...)`) won't be
detected.
## Out-of-process module sandboxing *(Since: v1.6)*
### The gap this closes
Capability-scoped modules (above) gate the *documented* `bread.*` API
surface — a module without `fs.read` sees `bread.fs == nil`. They never
gated Lua's own standard library: `os.execute`, `io.open`, `debug.*`
remained fully reachable from a scoped module's chunk regardless of what
its `bread` table contained, because that chunk still ran as ordinary Lua
code inside `breadd`'s own OS process, sharing its real filesystem/exec
access at the kernel level. A well-behaved module degrades correctly when
a permission is missing; a deliberately adversarial one just calls
`os.execute("cat /etc/shadow")` directly and the in-process mechanism has
nothing left to say about it.
This workstream closes that gap for any module that declares
`[[permissions]]` in `bread.module.toml` — including an explicit empty
list — by running it in a **separate OS process**, sandboxed at the kernel
level via [Landlock](https://docs.kernel.org/userspace-api/landlock.html),
instead of inside `breadd`'s own process.
### What still runs in-process
A module with **no manifest at all** (no `bread.module.toml`, or one with
no `permissions` key) keeps today's pre-v1.6 behavior unchanged: loaded
in-process, full ungated `bread` table, `os`/`io`/`debug` reachable —
surfaced as `"ungated": true` in `modules.list`/`state.get "modules"`,
which is exactly what `bread doctor` reads to warn about it. This is a
deliberate scope decision, not an oversight: Landlock needs concrete rules
to build a ruleset from, and "no manifest at all" carries no information
to build one. A module author who wants real OS-level isolation writes a
manifest — that's the whole point of the capability system this reuses.
Built-in modules (`bread.devices`/`monitors`/`workspaces`/`binds`) are
completely unaffected either way; they never go through manifest-based
scoping.
### Architecture
```
breadd (trusted) bread-module-host (sandboxed, per module)
│ │
├─ spawns child, applies a Landlock ──► │ (restriction applied by the
│ ruleset via Command::pre_exec │ PARENT before the child's
│ BEFORE execve() │ own main() ever runs)
│ │
├─ hands it a one-time token via │
│ $BREAD_MODULE_TOKEN (env, not argv) │
│ │
│◄── connects to breadd's existing ──────┤
│ IPC socket, presents the token │
│ via module_host.hello │
│ │
├─ looks up which module/permissions ──► │ learns its own identity +
│ the token was issued for, replies │ granted permissions from
│ │ breadd's answer (never
│ │ trusted from self-assertion)
│ │
│◄── module_host.on/off/emit/after/ ─────┤ loads init.lua into a fresh
│ every/cancel/fs_read/fs_write/ │ Lua VM; bread.* functions
│ exec/exec_capture/state_get/status │ are RPC-backed proxies, not
│ (RPC bridge, belt) │ direct bindings
│ │
│ Landlock ruleset (suspenders, │ os.execute/io.open/debug.*
│ enforced by the kernel independent │ still exist in this Lua VM
│ of whether the RPC bridge is used) ──►│ but are bounded by the
│ kernel regardless
```
One `bread-module-host` process per out-of-process module. Its own
dependency footprint is deliberately minimal (`mlua`, `tokio`,
`serde_json`, `bread-shared`) — it's reviewable attack surface in its own
right, running one module's untrusted Lua.
### The token/identity handshake
Workstream A deliberately did not build a generic IPC connection-identity
system — it closed a narrower spoofing gap instead — so there was no
`module:<name>` identity concept to reuse. `breadd` generates a random
one-time token (a v4 UUID) when spawning a module-host child and passes it
via the `$BREAD_MODULE_TOKEN` **environment variable**, not argv — argv is
visible to any process on the system via `/proc/<pid>/cmdline`, env vars
are not without `/proc/<pid>/environ` and matching privileges. The child's
first message on the IPC socket, `module_host.hello {token}`, presents
that token; `breadd` looks up which module name/permission set the token
was issued for (`ModuleHostRegistry::take_pending`, a one-time,
consume-on-read lookup) and replies with that identity. The child never
asserts its own name and has that trusted — an adversarial process holding
a *stolen or guessed* token still can't claim to be a different module
than the one `breadd` actually spawned that token for, and a token is
consumed on first use so it can't be replayed.
Other env vars passed to the child: `$BREAD_MODULE_ENTRY` (absolute path
to the module's `init.lua`) and `$BREAD_MODULE_SOCKET` (breadd's socket
path, for test harnesses that override it — production defaults to the
same `bread_shared::resolve_socket_path()` every other client uses).
`$BREAD_MODULE_NAME` is also passed, but purely informational (early log
lines before the hello handshake completes) — never trusted for identity
or permission lookup.
### The Landlock sandbox
[Landlock](https://docs.kernel.org/userspace-api/landlock.html) (Linux
5.13+) was chosen over wrapping every spawn in `bubblewrap`/`firejail`:
it's a pure-Rust crate calling the LSM's syscalls directly
(`landlock_create_ruleset`/`landlock_restrict_self`), unprivileged (no
setuid helper, no `CAP_SYS_ADMIN`), and fits this workspace's existing
preference for native Rust crates over shelling out to external tools
(same reasoning as `udev`/`zbus`/`rtnetlink` instead of CLI wrappers).
`bubblewrap`-wrapping remains a documented fallback for a target kernel
that lacks Landlock (pre-5.13, or compiled out) — not implemented, since
Landlock covers this project's actual target.
The ruleset is built in `breadd` (the parent) and applied via
`Command::pre_exec` — the closure runs in the forked child, after
`fork()` but before `execve()`, so the restriction covers the module-host
binary's own startup, not just the Lua that runs after. Because of that,
`bread-module-host` itself needs **zero** Landlock-related code or
dependency — by the time its `main()` runs, the restriction is already
active and inherited across the `execve()` that started it.
What the ruleset grants, from `breadd/src/module_host.rs`'s
`apply_sandbox`:
| Grant | Access | Why |
|-------|--------|-----|
| System library directories (`/usr/lib`, `/lib`, ...) + `/etc/ld.so.cache`/`.preload` | Read + **Execute** | The dynamic linker needs this to start *any* dynamically-linked binary at all — see the note below on why `Execute` is required here, not just `Read`. |
| The `bread-module-host` binary's own resolved path | Read + Execute | The one `execve()` this process is expected to have already performed. |
| The module's own directory (`init.lua`'s parent) | Read | So the bootstrap process can load the module's Lua at all — distinct from any `fs.read` grant, which governs the module's *own* runtime file I/O, not breadd's ability to hand it its own source. |
| `fs.read` with a `path` hint | Read, scoped to that (`~`-expanded) path prefix | Direct mapping from the manifest. |
| `fs.write` with a `path` hint | Read + Write + create, scoped to that path prefix | Matches `bread.fs.write`'s own `create_dir_all` + `write` behavior. |
| `exec` with a `bin` hint | Read + Execute, scoped to that binary's resolved path | Absolute paths used as-is; bare names resolved via a `$PATH` search, `which`-style. |
**No `fs.read`/`fs.write`/`exec` granted at all means no corresponding
Landlock rule exists, full stop** — the sandboxed process cannot read,
write, or execute anything outside the fixed baseline above, regardless
of what it tries via `os`/`io` directly.
**A note on `Execute` and shared libraries**: an earlier version of this
mechanism assumed Landlock's `Execute` right only gates `execve()`, and
that plain `Read` would be enough for the dynamic linker's `mmap(...,
PROT_EXEC, ...)` of `.so` files. That assumption was wrong — verified
empirically (not just reasoned about) by spawning a real sandboxed child:
with library directories restricted to `Read`-only, even `/bin/sh -c
"true"` failed to start at all (`EACCES` on `execve` before a single line
of script ran); granting `Execute` on those directories too fixed it. The
practical consequence: a module-host child's direct `os.execute`/`io.open`
escape hatch, if it names a path under a system library directory
specifically, is not denied the way an arbitrary path elsewhere is — the
baseline necessarily grants real `Execute` there. This is a materially
smaller exposure than no sandbox at all (bounded to files already shipped
in the system's own library directories, not the whole filesystem), but
it's a real, known trade-off, not swept under the rug. See
`breadd/src/module_host.rs`'s `apply_sandbox` doc comment for the full
reasoning, including why a fully static (`x86_64-unknown-linux-musl`)
build of `bread-module-host` — confirmed available on this project's dev
machine — would remove the need for this baseline entirely, and why that
wasn't attempted in this pass (a build/packaging change, not a sandbox
logic change).
**fs.read/fs.write with no `path` hint**: the RPC bridge's own
belt-and-suspenders permission check still applies, but no Landlock rule
is added — Landlock scoping needs a concrete path, and a hint-less grant
carries none. A module author who wants the direct `os`/`io` escape hatch
mediated at the kernel level too needs to declare a `path`.
**Network access is explicitly out of scope for this pass** (P2). Landlock
gained TCP bind/connect mediation in ABI v4+ (kernel 6.7+), but wiring a
`network` permission kind through the manifest schema and the sandbox
builder wasn't attempted here.
### RPC bridge coverage
`bread-module-host`'s `bread` table is built entirely from RPC-backed
proxies to `breadd` (`breadd/src/ipc/module_host_bridge.rs`), not direct
in-process bindings. Covered:
- **Baseline**, always present: `bread.on`/`.once`/`.off`/`.emit`,
`bread.after`/`.every`/`.cancel`, `bread.json.decode`, `bread.module`
(with a process-local `.store` — see the note below), `bread.log`/
`.warn`/`.error`. Also `bread.spawn`/`bread.wait` — the same pure-Lua
coroutine sugar `breadd`'s own `install_wait_helper` uses, since it's
built entirely on top of `on`/`once`/`after`/`cancel`, all of which are
bridged; the source is currently duplicated between `breadd` and
`bread-module-host` rather than extracted to `bread-shared` (flagged as
follow-up below).
- **Gated**, mirroring the permission table above: `bread.fs.read`/
`.write` (`fs.read`/`fs.write`), `bread.exec`/`.exec_capture` (`exec`),
`bread.state.get` (`state.read`).
Events/timers are delivered as unsolicited, tagged push messages
interleaved with ordinary request/response lines on the same connection
(`bread_shared::module_host_ipc::ModuleHostPush`) — a subscription
registered via `module_host.on`/`.once` is matched server-side against the
same event broadcast every other IPC subscriber reads from.
**Not yet bridged** (P1/P2 — see below): `bread.state.monitors`/
`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/
`.profile` shorthands, `bread.state.watch`, `bread.fs.exists`/`.readlink`/
`.expand`, `bread.profile.activate`, `bread.notify`, `bread.machine.*`,
`bread.hyprland.*`, `bread.widget.*`, `bread.bluetooth.*`,
`bread.wait_any`/`.wait_all`/`bread.workflow.*`. These namespaces are
simply absent (`nil`) from an out-of-process module's `bread` table
regardless of what the manifest grants — a real coverage gap versus the
in-process mechanism, not a permission-check bug.
**`bread.module().store` is process-local**, not synced back to `breadd`'s
`RuntimeState` — a real, known limitation versus the in-process mechanism
(where `M.store.set`/`.get` persists in daemon state and is visible to
`bread modules info`/other tooling). Fine for a module's own private
scratch state; not fine yet for anything expecting cross-process
visibility. Modules that need to report results/state externally should
use `bread.emit(...)` instead, which does cross the process boundary.
### Crash isolation
Each spawned `bread-module-host` child is reaped by a dedicated thread in
`breadd` (`std::process::Child::wait()`, blocking on that thread only —
never blocking the IPC server or the Lua engine). On exit for any reason —
clean shutdown, a Lua panic, `kill -9``breadd` emits
`bread.module.crashed` with `{ module, pid, reason, exit_code, signal }`
and updates that module's status. Verified end-to-end
(`breadd/tests/module_host_sandbox.rs`): killing a module-host child with
`SIGKILL` leaves `breadd` itself and every other module (in-process or
out-of-process) fully responsive, and the crash event fires with the
correct module name and `signal: 9`.
This is deliberately **detection and reporting**, not a restart/backoff
policy — a crashed module-host stays down until the next `bread reload`
(or daemon restart) respawns it. Richer supervision (auto-restart,
backoff, a circuit breaker) is flagged as follow-up work, not attempted
here.
### New IPC methods
*Since: v1.6 — `API_VERSION` bumped from `1.5.0` to `1.6.0` in
`breadd/src/ipc/mod.rs` for this addition.* All new methods live under the
`module_host.*` prefix and are only meaningful on a connection that has
completed the `module_host.hello` handshake (see the token/identity
section above) — see [Dictionary: IPC protocol](#dictionary-ipc-protocol)
for the full list alongside the pre-existing methods.
### What's implemented vs. deferred
**Landed (P0)**:
- The `bread-module-host` binary, spawn + token-based identity handshake.
- Real Landlock sandboxing built from a module's `ModulePermission` list,
independently verified at the OS level (`breadd/src/module_host.rs`'s
`landlock_denies_reads_outside_granted_path`/
`no_exec_permission_means_binary_cannot_be_executed_at_all` unit tests
against a real spawned child; `breadd/tests/module_host_sandbox.rs`'s
`os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope`
end-to-end, going through a real IPC handshake and real Lua calling
`os.execute`/`io.open` directly).
- RPC bridge for the baseline set plus `fs.read`/`fs.write`/`exec`/
`exec_capture`/`state.read` (`state.get` only).
- Crash isolation: kill-9 of a module-host child doesn't take `breadd` or
any other module down, and is reported via `bread.module.crashed`
(`breadd/tests/module_host_sandbox.rs`'s
`killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`).
**Landed beyond the minimum (still P0-adjacent)**:
- `bread.spawn`/`bread.wait` (pure-Lua coroutine sugar) work out-of-process
too, since they're built entirely on already-bridged primitives.
- `bread.state.get` (not originally required for the P0 minimum, added
because a pre-existing capability-manifest test exercised it).
**Deferred (P1 — do next if this workstream continues)**:
- `trust = "in-process"` manifest escape hatch for latency-sensitive
modules that want to opt back into today's D-mechanism deliberately.
- Extracting `bread.spawn`/`bread.wait`'s embedded Lua source (currently
duplicated between `breadd` and `bread-module-host`) into a shared
`bread-shared` module so the two copies can't drift.
- The remaining `bread.*` namespaces over RPC: `bread.state.watch` and the
`.monitors`/`.active_workspace`/etc. shorthands, `bread.fs.exists`/
`.readlink`/`.expand`, `bread.profile.activate`, `bread.notify`,
`bread.machine.*`, `bread.hyprland.*`, `bread.widget.*`,
`bread.bluetooth.*`, `bread.wait_any`/`.wait_all`/`bread.workflow.*`
mechanically the same pattern as the ones already bridged.
**Deferred (P2 — explicitly out of scope for this pass)**:
- Network sandboxing / a `network` permission kind.
- `bread modules info` showing the resolved sandbox profile.
- Full restart/backoff supervision policy for crashed module-hosts.
- A fully static (musl) build of `bread-module-host`, which would remove
the library-directory `Execute` baseline grant entirely.
- 100% RPC coverage of every remaining namespace.
## Debugging tips
- Run `bread events` to see live normalized events.
- Run `bread events --tree` *(Since: v1.5)* to render events as a causality tree instead of a flat stream — events that a Lua handler emitted via `bread.emit()` in reaction to another event are nested underneath it, following the `caused_by` chain (see [Dictionary: Event reference](#dictionary-event-reference)). Useful for untangling "why did this event fire" when several modules chain-react to each other.
- Run `bread state` to see full runtime state as JSON.
- Run `bread doctor` to check adapter and module health, including modules
running with full, ungated `bread.*` access because they have no
`permissions` declared.
- Log event payloads with `bread.log(tostring(event.data))`.
- Use `RUST_LOG=debug breadd` for verbose daemon output.
---
## Dictionary: Lua API
Every API is exposed through the `bread` global table.
### Module declaration
Every module must call `bread.module` exactly once at the top level.
```lua
local M = bread.module({
name = "my.module",
version = "0.1.0",
after = { "bread.devices" }, -- optional: load after this module
})
return M
```
If a module does not call `bread.module`, it fails to load and is marked as a load error.
### Events
#### `bread.on(pattern, fn) -> id`
Subscribe to matching events. Returns a numeric subscription ID.
```lua
local id = bread.on("bread.device.*", function(event)
-- event.event → the full event name string
-- event.data → table of event-specific fields
-- event.source → adapter that produced it ("Udev", "Hyprland", etc.)
bread.log(event.event)
end)
```
#### `bread.once(pattern, fn) -> id`
Subscribe once. The handler is removed after the first match.
#### `bread.filter(pattern, fn, opts) -> id`
Subscribe with a predicate. `opts` must contain a `filter` function:
```lua
bread.filter("bread.device.*", function(event)
bread.exec("xset r rate 200 40")
end, {
filter = function(event)
return event.data and event.data.class == "keyboard"
end,
})
```
#### `bread.off(id)`
Unsubscribe an event handler or state watch by ID.
#### `bread.emit(event, data)`
Emit a custom event into the system pipeline. Useful for cross-module communication. If called synchronously from inside a `bread.on` subscriber callback (i.e. in reaction to a matched event), the emitted event's `caused_by` *(Since: v1.5)* is set to the id of the event that triggered the callback, threading causality across chains of modules that react to each other — see [Dictionary: Event reference](#dictionary-event-reference).
#### `bread.wait(pattern, opts) -> event | nil`
Coroutine-only helper that suspends until a matching event arrives.
```lua
bread.spawn(function()
local event = bread.wait("bread.device.dock.connected", { timeout = 5000 })
if event then
bread.log("dock arrived")
end
end)
```
#### `bread.spawn(fn)`
Spawn a coroutine and surface errors if it fails. Required for using `bread.wait`.
#### `bread.wait_any(patterns, opts) -> event | nil` *(Since: v1.2)*
Coroutine-only. Like `bread.wait`, but resolves on the first of several patterns to match; returns `nil` after `opts.timeout` if none do.
```lua
bread.spawn(function()
local event = bread.wait_any(
{ "bread.monitor.connected", "bread.hyprland.event" },
{ timeout = 5000 }
)
if event then
bread.log("a monitor-related event arrived")
end
end)
```
#### `bread.wait_all(patterns, opts) -> table` *(Since: v1.2)*
Coroutine-only. Resolves once every listed pattern has fired at least once, or `opts.timeout` elapses. Returns a table keyed by pattern → event; on timeout, the table additionally has `timed_out = true` and contains whichever patterns had already fired.
### Workflows *(Since: v1.2)*
Multi-step automations built on `bread.spawn`/`bread.wait` (and `wait_any`/`wait_all`), with status introspectable from outside the running coroutine — via Lua (`bread.workflow.status`/`.list`) or over IPC (`workflows.list`). See [Examples.md](Examples.md#example-4-multi-step-automation-workflows) for a full worked example.
#### `bread.workflow.define(name, fn)`
Register a workflow body under `name`. `fn` receives one argument: whatever `opts.args` was passed to `.start()` (or `nil`).
#### `bread.workflow.start(name, opts)`
Run the workflow registered as `name` (spawned as a coroutine, same mechanics as `bread.spawn`). `opts` (optional):
| Key | Type | Description |
|-----|------|-------------|
| `deadline` | ms | If the workflow hasn't reached a terminal state by then, its status becomes `timed_out`. Independent of any per-`wait` timeout inside the body — a safety net for the whole run, not a replacement for step-level timeouts. |
| `args` | any | Passed through as the sole argument to the workflow body function. |
Starting a workflow under a name that's already running **replaces** its registry entry — this is a live-status registry, not a run history.
#### `bread.workflow.step(label)`
Call from inside a running workflow body to record "currently here." Purely observational — it does not affect control flow. Errors if called outside a running workflow body.
#### `bread.workflow.status(name) -> table | nil`
Returns the current status for `name`, or `nil` if no workflow with that name has ever been started. Shape:
```json
{
"name": "dock-connected",
"state": "running",
"step": "waiting for monitor",
"started_at": 1710000000000,
"updated_at": 1710000001500,
"error": null
}
```
`state` is one of `running`, `done`, `failed`, `timed_out`. `error` is set (the captured Lua error message) only when `state` is `failed`.
#### `bread.workflow.list() -> table`
Returns an array of every workflow's current status, in the same shape as `bread.workflow.status`.
### Widgets *(Since: v1.3)*
Declarative, live-updating widgets rendered by sibling `bread*` apps (breadbar) in their own bar/popover free space. A widget is a small tree of typed nodes — `box`, `label`, `icon`, `progress` — not raw markup: this keeps rendering generic across every consuming app and keeps a node's appearance confined to a bounded, typed `style` vocabulary the renderer already knows about (see `style` below), with no style/CSS injection surface from Lua.
Widgets are registered per-module and are re-registered fresh on every hot reload (the whole registry is cleared right before the Lua VM resets, same as `bread.module`'s per-reload re-execution) — call `bread.widget.register` at module top level or in `on_load`, not somewhere that only runs once ever.
#### `bread.widget.register(spec) -> ok, err`
Registers (or replaces, if `spec.id` already exists for this module) a widget. `spec`:
| Key | Type | Description |
|-----|------|-------------|
| `id` | string | Local id, unique within your module. Stored/addressed elsewhere as `"<module>.<id>"`. |
| `placement` | string | One of `tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats` — which fixed slot in the consuming app's layout this widget renders into. |
| `order` | number | Optional, default `0`. Sort priority within a placement; lower sorts first. |
| `visible` | bool | Optional, default `true`. |
| `tooltip` | string | Optional. |
| `root` | node | The render tree (see Node types below). |
Returns `true` on success, or `false, err` if `root` fails validation (tree too deep, too many nodes, or an invalid `class`), `root` contains a `style` field with a value outside its enum (a deserialization error, reported the same way), or `bread.widget.register` was called outside a module.
##### Node types
Every node accepts an optional `style` (a bounded, typed vocabulary — see below; this is the primary way to control a node's appearance), an optional `class` (a small freeform escape hatch, see [Style vs. class](#style-vs-class) below), and an optional `on_click` (any Lua value, passed through opaquely — see Click events below).
| `type` | Fields |
|--------|--------|
| `box` | `orientation` (`"horizontal"` \| `"vertical"`, default horizontal), `spacing`, `children` (array of nodes) |
| `label` | `text` |
| `icon` | `name` (bundled icon) or `path` (arbitrary SVG file) — exactly one; `size` |
| `progress` | `value` (0.01.0) |
A tree is capped at depth 4 (root counts as depth 1) and 50 total nodes — comfortably enough for a status readout, not enough to build a full custom UI.
```lua
bread.widget.register({
id = "weather",
placement = "left_of_stats",
tooltip = "Sydney: Partly cloudy",
root = {
type = "box",
children = {
{ type = "icon", name = "cloud" },
{ type = "label", text = "22°C", style = { color = "dim" }, on_click = "refresh" },
},
},
})
```
##### `style` *(Since: v1.4)*
`style` is a bounded, typed vocabulary for a node's appearance — every field is a small closed enum, not a string, so a typo is a `bread.widget.register` validation failure at registration time, not a silently-ignored CSS class. There is deliberately **no raw CSS/style-string field** anywhere in this API: a module can only ever pick from the fixed set below, never inject arbitrary style.
| Field | Type | Values |
|-------|------|--------|
| `color` | string | `fg`, `dim` (muted foreground), `accent`, `red`, `green`, `yellow`, `blue`, `pink`, `teal` |
| `weight` | string | `normal`, `bold` |
| `size` | string | `xs`, `sm`, `md`, `lg`, `xl` — text size in px (10/12/14/16/20); `sm`/`md` match the bread design system's own secondary/base font sizes |
| `align` | string | `start`, `center`, `end` |
| `background` | string | `none`, `surface`, `card` (surface + rounded corners + padding) |
| `radius` | string | `none`, `sm`, `md`, `full` (pill) |
| `padding` | string | `none`, `xs`, `sm`, `md` |
Every field is optional and independent — set only what you need. Colors, font sizes, radii, and padding all reuse the exact same palette, font, and spacing scale every other `bread*` GUI (breadbar, bos-settings, breadpad, ...) is themed from, so a widget recolors with the rest of the desktop when pywal's palette changes instead of drifting out of sync.
```lua
{ type = "label", text = "LOW BATTERY", style = { color = "yellow", weight = "bold" } }
```
##### Style vs. `class`
`class` still exists as an escape hatch for a CSS class the *consuming app's own stylesheet* happens to define (restricted to `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) — useful if you're targeting a specific app you know the internals of, but undiscoverable and app-specific otherwise. As of this writing, breadbar's stylesheet only gives real meaning to `dim` this way (fades a node to 60% opacity) — everything else a module needs (color, weight, size, alignment, background, radius, padding) should go through `style` instead, which every renderer is expected to understand identically.
#### `bread.widget.update(id, patch) -> ok, err`
Patches an already-registered widget (local `id`, not the fully-qualified form). Any of `root`, `tooltip`, `visible`, `order` may be given; omitted fields are left as-is. `root`, when given, replaces the whole tree — there is no node-level patching. Returns `false, "no such widget"` if `id` isn't registered.
```lua
bread.widget.update("weather", {
root = { type = "box", children = { { type = "label", text = "23°C" } } },
})
```
#### `bread.widget.remove(id) -> bool`
Removes a widget registered by the calling module. Returns whether anything was removed.
#### `bread.widget.list() -> table`
Returns an array of every widget the calling module currently has registered.
##### Click events
A clicked node's `on_click` value doesn't travel back through `breadd` directly — the rendering app (breadbar) emits `bread.bar.widget_clicked` with `{ widget_id, action }` (`action` being whatever you put in `on_click`), because a rendering app may only publish inside its own `bread.<app_id>.*` namespace (see [Namespaces](#namespaces)). React to it like any other event, filtering on `widget_id`:
```lua
bread.on("bread.bar.widget_clicked", function(e)
if e.data.widget_id == "weather.weather" then
-- e.data.action == "refresh"
end
end)
```
### State
#### `bread.state.get(path)`
Read a state subtree by dotted path.
```lua
local monitors = bread.state.get("monitors")
local online = bread.state.get("network.online")
```
#### Typed shorthands
```lua
bread.state.monitors()
bread.state.active_workspace()
bread.state.active_window()
bread.state.devices()
bread.state.power()
bread.state.network()
bread.state.profile()
```
#### `bread.state.watch(path, fn) -> id`
Watch a state path for changes. The callback receives `(new_value, old_value)`.
```lua
bread.state.watch("power.ac_connected", function(new_val, old_val)
if new_val then
bread.notify("AC connected")
end
end)
```
### Profiles
#### `bread.profile.activate(name)`
Activate a named profile. Emits `bread.profile.activated` over IPC.
### Execution
#### `bread.exec(cmd)`
Run a shell command. Fire-and-forget (async, does not block Lua).
#### `bread.exec_capture(cmd, opts) -> ok, stdout`
Run a shell command and return its result: `ok` is whether it exited zero,
`stdout` is its captured standard output. Unlike `bread.exec`, this blocks
the calling Lua callback until the command exits (or the timeout below
elapses), so it's only appropriate for fast, local commands — e.g.
`git -C <dir> rev-parse --abbrev-ref HEAD`, not anything that hits the
network or waits on user input.
```lua
local ok, branch = bread.exec_capture("git -C " .. dir .. " rev-parse --abbrev-ref HEAD")
if ok then
branch = branch:gsub("%s+$", "") -- trailing newline
end
```
Options:
| Key | Type | Default |
|-----|------|---------|
| `timeout_ms` | number | `2000` |
On timeout or spawn failure, returns `false, ""`.
### Notifications
#### `bread.notify(message, opts)`
Send a desktop notification via `notify-send`.
Options:
| Key | Type | Default |
|-----|------|---------|
| `title` | string | `"bread"` |
| `urgency` | string | from config |
| `timeout` | ms | from config |
| `icon` | string | none |
Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`.
### Timers
#### `bread.after(delay_ms, fn) -> id`
Run once after a delay.
#### `bread.every(interval_ms, fn) -> id`
Run on a repeating interval.
#### `bread.cancel(id)`
Cancel a timer created by `after` or `every`. Timers are also cancelled automatically on reload.
### Utilities
#### `bread.debounce(delay_ms, fn) -> wrapped_fn`
Returns a wrapper that fires only after `delay_ms` of quiet time.
```lua
local fn = bread.debounce(200, function(event)
reconfigure_monitors()
end)
bread.on("bread.monitor.**", fn)
```
#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`
Logging helpers. Accept any Lua value (coerced via `tostring`).
### Machine and filesystem
#### `bread.machine.name() -> string`
Returns the system hostname. If an external tool has written a
`~/.config/bread/sync.toml` with a `[machine].name`, that value takes
precedence (bread reads the file if present but does not create it).
#### `bread.machine.tags() -> string[]`
Returns `[machine].tags` from `~/.config/bread/sync.toml` if that file
exists, otherwise `{}`.
#### `bread.machine.has_tag(tag) -> bool`
Returns true if the machine has the given tag.
#### `bread.fs.write(path, content)`
Write a file. Creates parent directories as needed. `~` is expanded.
#### `bread.fs.read(path) -> string | nil`
Read a file. Returns `nil` if the file does not exist. `~` is expanded.
#### `bread.fs.exists(path) -> bool`
Returns true if the path exists. `~` is expanded.
#### `bread.fs.readlink(path) -> string | nil`
Read a symlink's target. Returns `nil` if the path doesn't exist or isn't a
symlink. Distinct from `bread.fs.read`, which opens and reads file
*contents* — for something like `/proc/<pid>/cwd`, the payload is the link
target itself, not a file to read.
#### `bread.fs.expand(path) -> string`
Expand `~` to the home directory.
#### `bread.json.decode(str) -> table | nil`
Parse a JSON string into a Lua table. Returns `nil` on malformed input.
Pairs naturally with `bread.exec_capture` for consuming JSON output from a
CLI (e.g. `kitty @ ls`).
### Hyprland
The `bread.hyprland` namespace provides compositor bindings.
```lua
-- Dispatch a Hyprland command
bread.hyprland.dispatch("workspace", "2")
bread.hyprland.dispatch("exec", "kitty")
-- Set a keyword
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
-- Send a raw request to the Hyprland socket, e.g. to evaluate a config-file
-- expression the way `hyprctl eval <expr>` does; returns the raw response string
local result = bread.hyprland.eval("some expression")
-- Query compositor state (returns deserialized Lua tables)
local win = bread.hyprland.active_window()
local monitors = bread.hyprland.monitors()
local workspaces = bread.hyprland.workspaces()
local clients = bread.hyprland.clients()
-- Subscribe to raw Hyprland events (bypasses normalization)
bread.hyprland.on_raw("activewindow", function(raw)
-- raw payload includes: kind, raw (original string), data
end)
```
### Bluetooth
The `bread.bluetooth` namespace provides control over the local Bluetooth adapter and its paired devices via BlueZ D-Bus. All functions degrade gracefully when BlueZ is unavailable — control functions log a warning and return `nil`, query functions return `nil`.
#### `bread.bluetooth.power(enabled)`
Power the Bluetooth adapter on (`true`) or off (`false`). Fire-and-forget.
#### `bread.bluetooth.powered() -> bool | nil`
Returns the current power state of the adapter, or `nil` if unavailable.
```lua
if bread.bluetooth.powered() then
bread.log("Bluetooth is on")
end
```
#### `bread.bluetooth.connect(address)`
Connect to a paired device by MAC address. Fire-and-forget — the result is delivered as a `bread.device.connected` event when the connection succeeds.
```lua
bread.bluetooth.connect("AA:BB:CC:DD:EE:FF")
```
#### `bread.bluetooth.disconnect(address)`
Disconnect from a device by MAC address. Fire-and-forget — delivered as `bread.device.disconnected`.
#### `bread.bluetooth.scan(enabled)`
Start (`true`) or stop (`false`) device discovery.
#### `bread.bluetooth.devices() -> table | nil`
Returns all devices known to BlueZ as an array of tables. Returns `nil` if BlueZ is unavailable.
```lua
local devs = bread.bluetooth.devices()
if devs then
for _, dev in ipairs(devs) do
bread.log(dev.name .. " " .. dev.address
.. (dev.connected and " [connected]" or ""))
end
end
```
Each device table:
| Field | Type | Description |
|-------|------|-------------|
| `address` | string | Bluetooth MAC address, e.g. `"AA:BB:CC:DD:EE:FF"` |
| `name` | string | Device name from BlueZ (Alias or Name property) |
| `connected` | bool | Whether the device is currently connected |
| `paired` | bool | Whether the device is paired |
#### Example: auto-connect headphones on AC power
```lua
local M = bread.module({ name = "headphones", version = "1.0.0" })
local HEADPHONES = "AA:BB:CC:DD:EE:FF"
function M.on_load()
bread.state.watch("power.ac_connected", function(ac)
if ac then
bread.bluetooth.power(true)
bread.bluetooth.connect(HEADPHONES)
end
end)
end
return M
```
#### Example: turn off Bluetooth on battery
```lua
bread.state.watch("power.ac_connected", function(ac)
bread.bluetooth.power(ac)
end)
```
### Module lifecycle hooks
All hooks are optional.
```lua
function M.on_load()
-- Called after the module loads. Register subscriptions here.
end
function M.on_reload()
-- Called after a hot reload completes across all modules.
end
function M.on_unload()
-- Called before the Lua instance is dropped.
end
function M.on_error(err)
-- Called when a subscription handler in this module throws.
-- Return true to keep the subscription alive, false to cancel it.
return true
end
```
### Module storage
Survives hot reload; does not survive daemon restart.
```lua
M.store.set("last_profile", "docked")
local value = M.store.get("last_profile")
```
Storage is scoped per module and is not shared across modules.
---
## Dictionary: Built-in modules
Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config.
### `bread.rules` *(Since: v1.5)*
The Lua side of the `rules.toml` declarative automation layer described in
[Getting started](#getting-started) — there is no separate API to call
here, it's driven entirely by `~/.config/bread/rules.toml`. Listed here (and
disable-able via `[modules].disable = ["bread.rules"]` like every other
built-in) because it's a real module the same way `bread.devices` is, just
one whose configuration lives in TOML instead of Lua.
```toml
# ~/.config/bread/rules.toml
[[rule]]
on = "device.dock.connected"
run = "~/.config/bread/scripts/dock-connected.sh"
[[rule]]
on = "power.ac.disconnected"
notify = "Unplugged"
[[rule]]
on = "device.keyboard.connected"
exec = "xset r rate 200 40"
```
Each rule's `on` becomes a `bread.on("bread." .. on, ...)` subscription —
see [Getting started](#getting-started) for the full `run`/`exec`/`notify`
semantics and validation rules. `rules.toml`'s absence is not an error;
parse/validation problems are reported the same way a broken hand-written
module's `on_load` error would be — via `bread doctor` / `modules.list`,
against the `bread.rules` module name.
### `bread.monitors`
High-level declarative monitor event handlers.
```lua
local monitors = require("bread.monitors")
monitors.layout("dock", function()
bread.exec("~/.config/bread/scripts/layout-dock.sh")
end)
monitors.on({
when = "connected",
monitors = { "HDMI-A-1" },
run = monitors.apply("dock"),
})
```
| Function | Description |
|----------|-------------|
| `M.on(opts)` | Register a monitor workflow. `opts`: `when`, `monitors` (optional list), `run` (function or shell string) |
| `M.layout(name, fn)` | Register a named layout function |
| `M.apply(name) -> fn` | Returns a function that calls the named layout |
`when` is one of `connected`, `disconnected`, `changed`.
### `bread.devices`
Device connection rules with name-based matching. This module handles hardware hotplug events from USB devices, monitors, and other peripherals.
Device names are defined in `~/.config/bread/devices.lua` — the daemon resolves the name before dispatching events, so modules can match on stable user-defined names rather than raw hardware identifiers.
```lua
local devices = require("bread.devices")
devices.on({
when = "connected",
device = "keyboard",
run = function(event)
bread.exec("xset r rate 200 40")
end,
})
devices.on({
when = "connected",
device = "dock",
run = "~/.config/bread/scripts/dock-connected.sh"
})
devices.on({
when = "disconnected",
name = "CalDigit", -- pattern-matched against event.data.name
run = function(event)
bread.log("Dock disconnected: " .. event.data.name)
end,
})
```
#### Functions
| Function | Description |
|----------|-------------|
| `M.on(opts)` | Register a device rule. See options below. |
#### Device rule options
```lua
devices.on({
when = "connected", -- required: "connected" or "disconnected"
device = "keyboard", -- optional: device name from devices.lua
name = "Keychron", -- optional: substring matched against device name
run = function(event) ... end -- required: function or shell string
})
```
- `when` (required): One of `connected` or `disconnected`.
- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name.
- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match).
- `run` (required): Function or shell string to run when the rule matches.
The callback receives the full device event:
```lua
{
event = "bread.device.dock.connected",
data = {
id = "/sys/...",
device = "dock", -- name resolved from devices.lua
name = "CalDigit TS4", -- raw device name from udev
subsystem = "usb",
vendor_id = "0x35f5",
product_id = "0x0104",
raw = { ... } -- full udev properties
}
}
```
#### Example: Keyboard configuration on connect
```lua
devices.on({
when = "connected",
device = "keyboard",
run = function(event)
bread.log("Keyboard connected: " .. event.data.name)
bread.exec("xset r rate 200 40")
end,
})
```
#### Example: Dock-specific setup
```lua
-- devices.lua defines: { device = "dock", vendor_id = "35f5" }
devices.on({
when = "connected",
device = "dock",
run = function(event)
bread.log("Dock connected")
bread.exec("~/.config/bread/scripts/dock-connected.sh")
end,
})
devices.on({
when = "disconnected",
device = "dock",
run = function(event)
bread.log("Dock disconnected")
bread.exec("~/.config/bread/scripts/dock-disconnected.sh")
end,
})
```
### `bread.workspaces`
Workspace-to-monitor assignment and app pinning.
```lua
local workspaces = require("bread.workspaces")
workspaces.assign("1", "HDMI-A-1")
workspaces.pin({ app = "Firefox", workspace = "2" })
```
| Function | Description |
|----------|-------------|
| `M.assign(workspace, monitor)` | Assign a workspace to a monitor |
| `M.pin(opts)` | Pin an app class to a workspace. `opts`: `app`, `workspace` |
| `M.apply_assignments()` | Apply all registered assignments via Hyprland dispatch |
### `bread.binds`
Runtime keybind management via Hyprland.
```lua
local binds = require("bread.binds")
binds.add({
mods = { "SUPER" },
key = "Return",
dispatch = "exec",
args = "kitty",
})
```
| Function | Description |
|----------|-------------|
| `M.add(opts)` | Add a keybind. `opts`: `mods`, `key`, `dispatch`, `args` |
| `M.remove(key)` | Remove a keybind by key |
| `M.replace(key, opts)` | Remove and re-add a keybind |
---
## Dictionary: Event reference
Events are delivered as a `BreadEvent`:
```json
{
"event": "bread.device.dock.connected",
"timestamp": 1710000000000,
"source": "Udev",
"data": {},
"id": "b3f2c9a0-4e6d-4b8a-9c1e-7a2f5d8e0c11",
"caused_by": null
}
```
- **`id`** *(Since: v1.5)* — a unique id assigned to this specific event instance at construction. Every `BreadEvent`, regardless of origin (adapter-normalized, IPC `emit`, Lua `bread.emit()`, or a daemon-internal send like `bread.system.startup`), gets one.
- **`caused_by`** *(Since: v1.5)* — the `id` of the event whose Lua subscriber handler emitted this event via `bread.emit()`, or `null` if this event did not originate from inside a running handler (adapter events, IPC `emit`, daemon-internal sends). This lets you reconstruct causality chains across modules that react to each other's events: if module A's handler for event X calls `bread.emit("Y", ...)`, then Y's `caused_by` is X's `id`. See `bread events --tree` below for a rendering of these chains.
### Pattern matching
| Pattern | Matches |
|---------|---------|
| `bread.device.dock.connected` | Exact match only |
| `bread.device.*` | One segment wildcard (does not cross `.`) |
| `bread.device.**` | Any depth under `bread.device` |
| `bread.monitor.?` | Single character within one segment |
### Normalized events
#### System
| Event | Data |
|-------|------|
| `bread.system.startup` | `{}` |
| `bread.module.crashed` *(Since: v1.6)* | `{ module, pid, reason, exit_code, signal }` — an out-of-process `bread-module-host` child exited (crash, panic, `kill -9`, ...). `exit_code`/`signal` are mutually exclusive (whichever applies); see [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16). |
#### Devices (udev / Bluetooth)
| Event | Data |
|-------|------|
| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` |
| `bread.device.disconnected` | same |
| `bread.device.<device>.connected` | `{ id, device }` |
| `bread.device.<device>.disconnected` | `{ id, device }` |
`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`.
Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bread.device.disconnected`. They can be distinguished by `event.data.subsystem`:
| `subsystem` | Source | Unique identifier field |
|-------------|--------|------------------------|
| `"usb"`, `"input"`, etc. | udev | `vendor_id` + `product_id` |
| `"bluetooth"` | BlueZ | `address` (MAC address) |
#### Bluetooth (BlueZ)
| Event | Data |
|-------|------|
| `bread.device.connected` | `{ id, device, name, address, subsystem: "bluetooth", raw }` |
| `bread.device.disconnected` | same |
| `bread.bluetooth.device.paired` | `{ id, name, address, subsystem: "bluetooth", raw }` |
| `bread.bluetooth.device.unpaired` | `{ id, address, subsystem: "bluetooth", raw }` |
`bread.bluetooth.device.paired` fires when BlueZ first learns about a device (new pairing or adapter restart). It does not mean the device is connected. `bread.device.connected` fires when the device profile actually connects.
`name` may be `"unknown"` on `bread.device.connected` events emitted from `PropertiesChanged` signals, since BlueZ only includes changed properties. It is always populated on `bread.bluetooth.device.paired` and on events from the initial enumeration at startup.
#### Hyprland
*Since: v1.5 — the `bread.hyprland.*` namespaced forms below. Bread's event vocabulary is meant to be portable across a future second compositor backend; a flat `bread.workspace.*`/`bread.monitor.*`/`bread.window.*` name gave no way to tell a genuinely cross-backend event (like `bread.power.*`) apart from one that is Hyprland-specific. The 10 rows marked `Deprecated: v1.5` are unaffected functionally — they keep firing — but new automation should subscribe to their `bread.hyprland.*` sibling instead.*
Every Hyprland-sourced event below is dual-emitted: the daemon fires both the legacy flat name and its `bread.hyprland.<rest>` equivalent with identical `data`/`timestamp`/`source`, unless `[compat] legacy_hyprland_event_names = false` is set (see below), in which case only the namespaced name fires. A module that subscribes only to `bread.hyprland.*` always gets full workspace/monitor/window coverage regardless of that setting.
| Event | Data |
|-------|------|
| `bread.workspace.changed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.changed`)* | raw payload |
| `bread.hyprland.workspace.changed` *(Since: v1.5)* | raw payload |
| `bread.workspace.created` *(Deprecated: v1.5 — use `bread.hyprland.workspace.created`)* | `{ workspace }` |
| `bread.hyprland.workspace.created` *(Since: v1.5)* | `{ workspace }` |
| `bread.workspace.destroyed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.destroyed`)* | `{ workspace }` |
| `bread.hyprland.workspace.destroyed` *(Since: v1.5)* | `{ workspace }` |
| `bread.monitor.connected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.connected`)* | raw payload |
| `bread.hyprland.monitor.connected` *(Since: v1.5)* | raw payload |
| `bread.monitor.disconnected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.disconnected`)* | raw payload |
| `bread.hyprland.monitor.disconnected` *(Since: v1.5)* | raw payload |
| `bread.window.focus.changed` *(Deprecated: v1.5 — use `bread.hyprland.window.focus.changed`)* | raw payload |
| `bread.hyprland.window.focus.changed` *(Since: v1.5)* | raw payload |
| `bread.window.focused` *(Deprecated: v1.5 — use `bread.hyprland.window.focused`)* | `{ address }` |
| `bread.hyprland.window.focused` *(Since: v1.5)* | `{ address }` |
| `bread.window.opened` *(Deprecated: v1.5 — use `bread.hyprland.window.opened`)* | `{ address, workspace, class, title }` |
| `bread.hyprland.window.opened` *(Since: v1.5)* | `{ address, workspace, class, title }` |
| `bread.window.closed` *(Deprecated: v1.5 — use `bread.hyprland.window.closed`)* | `{ address }` |
| `bread.hyprland.window.closed` *(Since: v1.5)* | `{ address }` |
| `bread.window.moved` *(Deprecated: v1.5 — use `bread.hyprland.window.moved`)* | `{ address, workspace }` |
| `bread.hyprland.window.moved` *(Since: v1.5)* | `{ address, workspace }` |
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds — already namespaced, not part of this migration) |
| `bread.hyprland.snapshot` *(Since: v1.7.1)* | `{ monitors, workspaces, active_workspace, active_window }` — emitted once after the Hyprland event socket connects (and again after a reconnect). `bread.state` applies this event to replace compositor topology so monitors/workspaces/focus are populated before the next live event. Not dual-emitted under a legacy name. |
##### Compatibility: `[compat]` config
```toml
[compat]
legacy_hyprland_event_names = true # default during the deprecation window
```
Set to `false` to suppress the 10 legacy flat names above and emit only their `bread.hyprland.*` equivalents. This defaults to `true` for now; per the [API Stability & Versioning](#api-stability--versioning) deprecation-window policy, the default will flip to `false` in a later release once the window closes. Removing the legacy names entirely is a further, separate follow-up — see the note in `DEPRECATIONS.md`.
#### Power
| Event | Data |
|-------|------|
| `bread.power.ac.connected` | `{ ac_connected, battery_percent }` |
| `bread.power.ac.disconnected` | `{ ac_connected, battery_percent }` |
| `bread.power.battery.low` | `{ battery_percent }` |
| `bread.power.battery.very_low` | `{ battery_percent }` |
| `bread.power.battery.critical` | `{ battery_percent }` |
| `bread.power.battery.full` | `{ battery_percent }` |
| `bread.power.changed` | `{ ac_connected, battery_percent }` |
#### Network
| Event | Data |
|-------|------|
| `bread.network.connected` | `{ online, interfaces }` |
| `bread.network.disconnected` | `{ online, interfaces }` |
#### System events
| Event | Data |
|-------|------|
| `bread.profile.activated` | `{ name }` |
| `bread.notify.sent` | `{ title, message, urgency }` |
| `bread.state.changed.<path>` | emitted by state watches |
#### Widgets *(Since: v1.3)*
Emitted by `breadd` itself on every `bread.widget.*` mutation — see [Widgets](#widgets-since-v13). `data` is the full `WidgetSpec` for `registered`/`updated`; just `{ id }` for `removed`.
| Event | Data |
|-------|------|
| `bread.widget.registered` | `{ id, module, placement, order, visible, tooltip, root, updated_at }` |
| `bread.widget.updated` | same shape as `registered` |
| `bread.widget.removed` | `{ id }` |
| `bread.widget.cleared` | `{}` — fired once at the end of every module reload (`bread reload`), whether or not the widget set actually changed. The registry itself is wiped and re-populated as modules re-run; this is a "go re-fetch" signal for consumers that only react to `bread.widget.*` events, so a module that stops registering widgets (e.g. gets disabled) is noticed even though nothing else fires. |
#### Terminal (shell precmd/preexec hooks)
Requires `bread hooks install shell` and sourcing the generated script from your shell rc — see the CLI reference. Fires via the `bread-emit` helper, not the daemon reaching out.
| Event | Data |
|-------|------|
| `bread.terminal.command.started` | `{ cmd, cwd }` |
| `bread.terminal.command.finished` | `{ cmd, cwd, exit_code, duration_ms }` |
| `bread.terminal.cwd.changed` | `{ cwd, prev_cwd }` |
Terminal events are exempt from the daemon's event dedup window (running the same command twice in quick succession is legitimate, not noise).
#### Git (hooks + dirty-state poller)
`bread.git.commit.created`/`bread.git.branch.changed` come from git hooks installed via `bread hooks install git` (current repo only; never overwrites an existing hook). `bread.git.state.*`/`bread.git.ahead_behind.changed` come from an in-daemon poller over configured project roots (`[adapters.git] roots = [...]` in `breadd.toml`) and never fire for the same transition a hook already reported.
| Event | Data |
|-------|------|
| `bread.git.commit.created` | `{ repo, sha, branch, message }` |
| `bread.git.branch.changed` | `{ repo, branch, previous_ref }` |
| `bread.git.state.dirty` | `{ repo }` |
| `bread.git.state.clean` | `{ repo }` |
| `bread.git.ahead_behind.changed` | `{ repo, ahead, behind, branch }` |
#### Filesystem / project detection
Scoped to configured project roots (`[adapters.filesystem] roots = [...]`), not the whole filesystem. `.git`/`node_modules` are always silent; `target`/`dist`/`build` are silent for edits but reported on new-file creation as `build_artifact.created`.
| Event | Data |
|-------|------|
| `bread.project.detected` | `{ root, markers }` (markers: any of `.git`, `Cargo.toml`, `package.json`, `go.mod`) |
| `bread.project.file.changed` | `{ path, project_root }` |
| `bread.project.build_artifact.created` | `{ path, project_root }` |
#### Systemd (`systemd --user` units)
Only units named in `[adapters.systemd] units = [...]` are watched — subscribing to every user unit is noisy.
| Event | Data |
|-------|------|
| `bread.service.started` | `{ unit }` |
| `bread.service.stopped` | `{ unit }` |
| `bread.service.failed` | `{ unit, result }` (`result` may be `null`) |
#### Podman (containers)
Degrades to simply not emitting if the `podman` binary isn't installed — no daemon startup dependency on it.
| Event | Data |
|-------|------|
| `bread.container.started` | `{ id, name, image }` |
| `bread.container.stopped` | `{ id, name }` |
| `bread.container.health.changed` | `{ id, name, health }` |
#### Remote (SSH session detection)
Rides the same shell-hook transport as Terminal events (`bread hooks install shell`).
| Event | Data |
|-------|------|
| `bread.remote.session.started` | `{ host }` |
| `bread.remote.session.ended` | `{ host }` |
---
## Namespaces
*Since: v1.1 — the `AdapterSource::App` variant and the known-apps registry (`bread_shared::apps::KNOWN_APPS`). No sibling app emits through this path yet as of this writing except the breadclip pilot (see its own `EVENTS.md` once that lands); the daemon-side plumbing and the convention itself are what v1.1 adds.*
*Since: v1.3 — breadbar is now an active `bread-client` consumer under the `bar` app id (already present in `KNOWN_APPS`): it emits `bread.bar.widget_clicked` for widget clicks (see [Widgets](#widgets-since-v13)) and reads `bread.widget.*` to render the [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)'s `widgets` field.*
Two dotted-name segments are reserved, permanent parts of the schema — not one-off conventions:
- **`bread.<app>.*`** — inbound events published *by* a sibling `bread*` application about its own state (e.g. `bread.clip.copied`). An app may only publish within its own segment; the daemon enforces this at the IPC boundary (a socket client claiming a `source` of an app id it doesn't own is rejected the same way spoofing `power`/`hyprland` is rejected today).
- **`bread.command.<app>.<verb>`** — outbound commands *to* a sibling application (e.g. `bread.command.clip.clear`). Any module or app may publish; only the target app subscribes. This reuses the existing event bus in both directions — there is no separate request/response protocol. *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` names (`known-app` ∈ `KNOWN_APPS`, verb a non-empty extra dotted segment) are allowed on the unsourced/`bread-emit` path and via sourced `AdapterSource::App` emit (an app may publish a command to another known app). `BreadClient::command` in bread-utils is the typed helper for the same path. `command` remains in `RESERVED_DOMAINS` so it cannot be claimed as an app id; `bread.command.power.off` and `bread.command.notanapp.x` are still rejected. See [Dictionary: IPC protocol](#dictionary-ipc-protocol).*
- The second dotted segment is drawn from a small known-apps registry (`bread_shared::apps::KNOWN_APPS` in `bread-shared/src/apps.rs`); daemon-internal domains (`terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow`) are reserved and cannot be claimed as app ids. *Since: v1.5 — `bluetooth`, `workspace`, `window`, and `monitor` added to this list (event families the Bluetooth and Hyprland adapters already published under, but that were missing from it); this same list is now also the boundary the IPC `emit` method's no-`source` path checks event names against, see [Dictionary: IPC protocol](#dictionary-ipc-protocol).*
- **Commands are best-effort.** Publishing `bread.command.<app>.<verb>` with no subscriber (the app isn't installed or isn't running) is a silent no-op — there is nothing to special-case, and no error is raised. An app that acts on a command *should* emit a corresponding `bread.<app>.<verb>.done` (or `.failed`) confirmation; a module that needs to know a command was actually honored must `bread.wait`/`bread.wait_any` on that confirmation with a timeout rather than assume success. There is no mandatory request/response correlation layer — most commands are legitimately fire-and-forget, and building one would contradict the "no listener, no-op" degradation property.
- **`bread.exec("<cli> ...")`** remains the zero-infrastructure fallback for triggering a sibling app that has a synchronous CLI and no need for a structured response.
---
## Integrating a bread\* app
This is the checklist for adding a new sibling `bread*` application to the fabric — it's deliberately short, because the whole design goal of the name-based app registry (over one `AdapterSource` enum variant per app) is that this never requires a daemon change beyond step 1. **breadclip is the reference implementation** — see its own `EVENTS.md` for a worked example of every step below.
1. **Register your app id.** Add it to `KNOWN_APPS` in `bread-shared/src/apps.rs` (a one-line, one-word-per-app list) — this is the only change to the `bread` repo itself a new integration needs.
2. **Depend on `bread-utils` with the `bread-client` feature.** In your app's daemon (the long-running piece, if you have one — a short-lived CLI tool can use `bread-emit` instead, see below), add `bread-utils = { ..., features = ["bread-client"] }` and use `bread_utils::bread_client::BreadClient`:
- `BreadClient::connect(app_id)` — cheap, cannot fail (there is no persistent connection to fail at construction time).
- `client.emit(event, data)` — publish within your own `bread.<app_id>.*` namespace. Each call is its own short-lived connection (fire-and-forget, like `bread-emit`) — safe to call from a short-lived per-event process invocation, not just from inside a long-running loop.
- `client.command(target, verb, data)` — publish `bread.command.<target>.<verb>` to another known app. Same fire-and-forget socket write as `emit`; this is the typed helper for the command-bus path that `bread-emit bread.command.<app>.<verb>` uses. *Since: v1.7 — the daemon actually accepts these on the unsourced and sourced-app emit paths; see [Namespaces](#namespaces).*
- `client.subscribe("bread.command.<app_id>.**", |event| { ... })` — receive commands addressed to you, on a background thread with its own reconnect/backoff loop.
3. **If you don't have a persistent daemon at all** (just a CLI tool invoked occasionally), skip `bread-client` entirely and shell out to `bread-emit` instead (see `bread-emit`'s own `--help`) — it's built for exactly that case (occasional callers that can't justify holding a socket open).
4. **Emit confirmations for commands you honor.** `bread.<app_id>.<verb>.done` or `.failed` after acting on a `bread.command.<app_id>.<verb>` — optional, but it's what lets a Lua workflow `bread.wait`/`bread.wait_any` for the real outcome instead of assuming success the moment it publishes a command.
5. **Write an `EVENTS.md`** in your app's own repo cataloguing every event you publish and every command verb you honor, with `data` shapes — the per-app companion to this file. Be honest about what's *not* implemented yet rather than stubbing a verb that does nothing (see breadclip's `EVENTS.md` for how it documents `pin`/`select` as deliberately deferred, not silently dropped).
6. **Make it opt-out, not opt-in-only, and fail silent.** Your app should work exactly the same whether breadd is installed or not — connecting/emitting/subscribing must never block, error, or crash your app just because the daemon is absent. `BreadClient` is built this way already (dropped no-op on a failed `emit`, transparent reconnect on `subscribe`); if you roll your own transport instead, keep that property.
---
## Dictionary: Runtime state schema
`bread state` and `bread.state.get("")` return the full `RuntimeState`:
```json
{
"monitors": [
{ "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null }
],
"workspaces": [
{ "id": "1", "monitor": "HDMI-A-1" }
],
"active_workspace": "1",
"active_window": "0x...",
"devices": {
"connected": [
{
"id": "/sys/...",
"name": "CalDigit TS4",
"device": "dock",
"subsystem": "usb",
"vendor_id": "0x35f5",
"product_id": "0x0104"
}
]
},
"network": {
"interfaces": { "eth0": { "up": true } },
"online": true
},
"power": {
"ac_connected": true,
"battery_percent": 87,
"battery_low": false
},
"profile": {
"active": "default",
"history": [],
"profiles": {}
},
"modules": [
{
"name": "bread.monitors",
"status": "loaded",
"last_error": null,
"builtin": true,
"store": {}
}
],
"workflows": [
{
"name": "dock-connected",
"state": "running",
"step": "waiting for monitor",
"started_at": 1710000000000,
"updated_at": 1710000001500,
"error": null
}
],
"widgets": [
{
"id": "weather.weather",
"module": "weather",
"placement": "left_of_stats",
"order": 0,
"visible": true,
"tooltip": "Sydney: Partly cloudy",
"root": {
"type": "box",
"orientation": "horizontal",
"children": [
{ "type": "icon", "name": "cloud" },
{ "type": "label", "text": "22°C" }
]
},
"updated_at": 1710000001500
}
]
}
```
`modules[].status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. `workflows[].state` values: `running`, `done`, `failed`, `timed_out` *(Since: v1.2 — see [Workflows](#workflows-since-v12))*. `widgets[].placement` values: `tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats` *(Since: v1.3 — see [Widgets](#widgets-since-v13))*.
---
## Dictionary: IPC protocol
The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON.
Request:
```json
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
```
Response:
```json
{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] }
```
Available methods:
| Method | Params | Description |
|--------|--------|-------------|
| `ping` | — | Connectivity check |
| `health` | — | Version, uptime, PID, adapter status, `api_version` |
| `state.get` | `key` (dotted path) | Read a value from `RuntimeState` |
| `state.dump` | — | Return the full `RuntimeState` as JSON |
| `modules.list` | — | List all loaded modules and their status |
| `modules.reload` | — | Hot-reload the Lua runtime |
| `profile.list` | — | List defined profiles |
| `profile.activate` | `name` | Switch active profile |
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
| `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly, tagged `Manual` *(Since: v1.5 — previously tagged `System`; see below)*, for manually testing Lua handlers (this is what `bread emit <event>` and `bread-emit` use). Well-formed `bread.command.<known-app>.<verb>` is allowed on this path *(Since: v1.7)*; other reserved domains stay rejected. With `source` set to `terminal`/`git`/`remote`, or a registered sibling-app id (see [Namespaces](#namespaces)), builds a real `RawEvent` (requires `kind` too) that goes through the normalizer like any adapter. A sourced app may also publish a well-formed command to another known app. Any other `source` value is rejected — this is the anti-spoofing boundary that stops a socket client from forging e.g. `power`/`hyprland` events. |
| `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* |
| `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* |
*Since: v1.6* — `module_host.*`: the RPC bridge an out-of-process
`bread-module-host` child uses in place of direct in-process `bread.*`
bindings (see [Out-of-process module
sandboxing](#out-of-process-module-sandboxing-since-v16)). Meaningful only
on a connection that has completed the handshake below; not intended for
direct use by other clients.
| Method | Params | Description |
|--------|--------|-------------|
| `module_host.hello` | `token` | One-time handshake. Consumes the token, replies with `{ module, permissions, api_version }` or an error for an unknown/expired token. Takes over the rest of the connection's lifetime as a bidirectional RPC bridge, same as `events.subscribe` does for a plain event stream. |
| `module_host.on` / `.once` | `pattern` | Subscribe; replies `{ subscription_id }`. Matches are pushed asynchronously as `{"push":"event", subscription_id, event}` lines interleaved with ordinary responses. |
| `module_host.off` | `id` | Cancel a subscription. |
| `module_host.after` / `.every` | `delay_ms` / `interval_ms` | Server-managed timer; replies `{ timer_id }`. Fires are pushed as `{"push":"timer", timer_id}`. |
| `module_host.cancel` | `id` | Cancel a timer. |
| `module_host.emit` | `event`, `data` | Same manual-emit semantics (and reserved-domain guard) as the top-level `emit` method. |
| `module_host.log` / `.warn` / `.error` | `message` | Forwarded to `breadd`'s own tracing log, prefixed with the module name. |
| `module_host.fs_read` | `path` | Requires `fs.read` granted; path-prefix-checked against the manifest's `path` hint if one was declared. Replies `{ content }` (`null` if unreadable). |
| `module_host.fs_write` | `path`, `content` | Requires `fs.write`, same scoping check. |
| `module_host.exec` | `cmd` | Requires `exec`; `bin`-hint-checked (by leading command word) if declared. Fire-and-forget, matching `bread.exec`'s own semantics. |
| `module_host.exec_capture` | `cmd`, `timeout_ms` | Requires `exec`. Replies `{ ok, stdout }`. |
| `module_host.state_get` | `key` | Requires `state.read`. Replies `{ value }`. |
| `module_host.status` | `state` (`"loaded"`\|`"load_error"`), `error` | The module-host reports its own load outcome after running `init.lua`; updates `modules.list` status and unblocks `breadd`'s spawn-side wait. |
Every gated method above checks the module's granted `PermissionKind`s
(learned at hello-time) before attempting the call — belt-and-suspenders
alongside the Landlock sandbox enforced at the OS level on the
module-host process itself, not a replacement for it.
The `health` response's `api_version` field lets a client — the CLI, a Lua module via `bread.exec`, or a `bread-client`-linked sibling app — assert compatibility with this document's versioned schema at connect time (see [API Stability & Versioning](#api-stability--versioning)).
*Since: v1.5 — `emit` without `source` closed a spoofing gap: previously any event name was accepted with zero validation and tagged `System`, the same tag the daemon uses internally for events it originates itself in Rust code (`bread.system.startup`, `bread.profile.activated`, ...). That made a manually-injected event indistinguishable from a trusted, daemon-originated one. Now:*
- *The unsourced path is tagged `AdapterSource::Manual`, not `System` — `System` is reserved for the daemon's own Rust-originated sends and can no longer be produced from data that arrived over the IPC socket.*
- *The event name is rejected if its top-level dotted segment (the part right after `bread.`) is one of the reserved, adapter-owned domains in `bread_shared::apps::RESERVED_DOMAINS` — `terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow` (see [Namespaces](#namespaces)) — since a socket client emitting e.g. `bread.power.ac.connected` this way would otherwise be indistinguishable from the real power adapter observing it.*
- *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` is an explicit exception to that reserved-domain reject (`command` stays reserved so it cannot be claimed as an app id). `bread.command.clip.clear` is accepted unsourced and as a sourced `AdapterSource::App` emit from another known app; `bread.command.power.off`, `bread.command.notanapp.x`, and `bread.hyprland.*` are still rejected. `API_VERSION` bumped from `1.6.0` to `1.7.0` for this addition.*
- *Since: v1.7.1 — the state engine applies both legacy Hyprland names and `bread.hyprland.*` (so flipping `[compat] legacy_hyprland_event_names = false` no longer freezes monitors/workspace/window). `RuntimeState.workspaces` is written on `workspace.created`/`destroyed` and replaced by `bread.hyprland.snapshot`. `API_VERSION` bumped from `1.7.0` to `1.7.1`.*
- *Freely-named custom/test event names (anything outside those reserved domains, including names with no `bread.` prefix at all) remain unrestricted — this is what keeps `bread emit <name>` useful for testing Lua handlers without unplugging cables, and what `bread-emit`'s fire-and-forget, no-reply-wait design still works against unchanged (a single JSON line write is still sufficient; no handshake was added).*