Security: - Remove `bread modules install github:…`. Remote fetch pulled unreviewed third-party Lua and ran it with full bread.exec() privileges in an unsandboxed runtime. Module install is now local-only; parse_source rejects github:/git: with an explicit message. bread-sync extracted from the workspace (parked for its own project): - Removed from workspace members (now excluded); see bread-sync/EXTRACTION.md - Removed the entire `bread sync` CLI surface and now-unused deps (bread-sync, reqwest, tar, flate2; tempfile demoted to dev-dependency) - Removed the sync.status IPC method from breadd plus its integration tests - Moved the generic `expand_path` helper into bread-shared (with unit tests) CI now actually runs and gates quality: - Trigger on master/dev (was `main` — CI had never run, not once) - Added `cargo fmt --check` and `clippy -D warnings`; fixed 4 clippy warnings - Dropped the macOS matrix entry (breadd is Linux-only: udev/rtnetlink); added the libudev-dev system dependency the Linux build needs Hardening / honesty: - New ipc test: daemon survives repeated reloads and the event pipeline resumes (the prior suite only had a single happy-path reload check) - Docs scrubbed of sync across README/Documentation/Overview/DAEMON - "production-ready" and "compositor-agnostic" claims reworded to match reality rather than aspiration Note: bread-sync/src/export.rs held pre-existing local WIP authored outside this change set and is intentionally excluded from this commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
23 KiB
Bread Documentation
Contents
- Overview
- Getting started
- Your first module
- Run, reload, and watch
- Modules: install and manage
- Debugging tips
- Dictionary: Lua API
- Dictionary: Built-in modules
- Dictionary: Event reference
- Dictionary: Runtime state schema
- 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, and BlueZ Bluetooth.
If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details.
Getting started
1) Create a minimal config
- Daemon config:
~/.config/bread/breadd.toml(all values optional) - Lua entry point:
~/.config/bread/init.lua - Lua modules:
~/.config/bread/modules/
2) Minimal init.lua
bread.on("bread.system.startup", function(event)
bread.profile.activate("default")
bread.log("bread started on " .. bread.machine.name())
end)
3) Start the daemon
systemctl --user start breadd
# Or directly:
breadd
4) 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.moduleexactly once at the top level. - Register subscriptions inside
M.on_loadso they are cleaned up properly on hot reload. - Use
bread.logearly to verify handlers are firing.
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
bread modules info 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"
Debugging tips
- Run
bread eventsto see live normalized events. - Run
bread stateto see full runtime state as JSON. - Run
bread doctorto check adapter and module health. - Log event payloads with
bread.log(tostring(event.data)). - Use
RUST_LOG=debug breaddfor 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.
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.
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).
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.
bread.fs.expand(path) -> string
Expand ~ to the home directory.
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")
-- 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.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 ofconnectedordisconnected.device(optional): Device name as defined indevices.lua. If specified, the rule only fires for devices with that name.name(optional): Pattern that must be found inevent.data.name(case-insensitive substring). Can be combined withdevice(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": {}
}
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 |
{} |
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
| Event | Data |
|---|---|
bread.workspace.changed |
raw payload |
bread.workspace.created |
{ workspace } |
bread.workspace.destroyed |
{ workspace } |
bread.monitor.connected |
raw payload |
bread.monitor.disconnected |
raw payload |
bread.window.focus.changed |
raw payload |
bread.window.focused |
{ address } |
bread.window.opened |
{ address, workspace, class, title } |
bread.window.closed |
{ address } |
bread.window.moved |
{ address, workspace } |
bread.hyprland.event |
{ kind, raw, data } (unhandled kinds) |
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 |
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": {}
}
]
}
status values: loaded, load_error, not_found, degraded, disabled.
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 |
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 |
Inject a synthetic event into the pipeline |