bread/Documentation.md
Breadway d3517d1433 Allow well-formed bread.command events on the emit bus
Docs already said any module or app could publish bread.command.<app>.<verb>,
but command is reserved so both unsourced bread-emit and sourced App emit
rejected the whole namespace. Keep command unclaimable as an app id; accept
bread.command.<known-app>.<verb> (and let an app command another known app).
Also ship bread-emit and bread-module-host, and give udev enumerate the same
classification fields as a live add so boot-time devices are not all unknown.
2026-08-15 21:41:40 +08:00

85 KiB
Raw Blame History

Bread Documentation

Contents

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.

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), 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 CLAUDE.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:

[[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) 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 and the next section.

3) Minimal init.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

systemctl --user start breadd

# Or directly:
breadd

5) Check that it's running

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.

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.

Run, reload, and watch

# 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.

# 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:

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 absentbread.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 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:

[[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 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 requires 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:

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, 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 (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 -9breadd 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 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). 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.

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.

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:

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.

bread.wait(pattern, opts) -> event | nil

Coroutine-only helper that suspends until a matching event arrives.

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.

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 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:

{
  "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 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.

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.

{ 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.

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). React to it like any other event, filtering on widget_id:

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.

local monitors = bread.state.get("monitors")
local online   = bread.state.get("network.online")

Typed shorthands

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

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.

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.

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.

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.

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

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.

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.

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

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

bread.state.watch("power.ac_connected", function(ac)
    bread.bluetooth.power(ac)
end)

Module lifecycle hooks

All hooks are optional.

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.

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 — 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.

# ~/.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 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.

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.

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

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:

{
  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

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

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

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.

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:

{
  "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.

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)
Compatibility: [compat] config
[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 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. 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) and reads bread.widget.* to render the 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-appKNOWN_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.
  • 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.
  • 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.
    • 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:

{
  "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). widgets[].placement values: tray, left_of_clock, right_of_clock, right_of_workspaces, left_of_stats (Since: v1.3 — see Widgets).


Dictionary: IPC protocol

The daemon exposes a Unix socket at $XDG_RUNTIME_DIR/bread/breadd.sock. Messages are newline-delimited JSON.

Request:

{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }

Response:

{ "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), 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.6module_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). 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 PermissionKinds (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).

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 SystemSystem 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_DOMAINSterminal, git, hyprland, device, power, network, bluetooth, workspace, window, monitor, service, container, project, remote, system, profile, notify, command, workflow (see 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.
  • 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).