bread/Documentation.md
Breadway 434fe1721c feat: enhance device normalization and classification
- Introduced a new mechanism in EventNormalizer to suppress duplicate events from child nodes of the same physical device.
- Removed the device classification logic from the normalizer and replaced it with a rule-based system using Lua scripts.
- Added support for user-defined device rules in Lua, allowing for flexible device naming based on various conditions.
- Updated the state engine to handle device rules and resolve device names before dispatching events.
- Modified the installation script to set up default configuration files for the daemon and Lua modules.
- Improved the handling of systemd user services to dynamically set the ExecStart path based on the installation directory.
2026-05-12 21:27:07 +08:00

21 KiB

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

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

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.

# Install from GitHub (downloads and extracts the default branch tarball)
bread modules install github:someuser/bread-wifi

# Install from a local directory
bread modules install ~/src/my-module

# Install a specific ref
bread modules install github:someuser/bread-wifi@v1.2.0

# List installed modules and their daemon status
bread modules list

# Show full manifest for one module
bread modules info bread-wifi

# Re-install all GitHub-sourced modules (pick up upstream changes)
bread modules update

# 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 = "github:someuser/bread-wifi"
installed_at = "2026-01-01T00:00:00Z"

Sync: snapshot and restore

Bread sync snapshots your Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state.

# First-time setup
bread sync init --remote git@github.com:you/bread-config.git

# Snapshot and push
bread sync push

# On another machine: pull and apply
bread sync pull

# Also reinstall packages from snapshot
bread sync pull --install-packages

# See what has changed
bread sync status
bread sync diff
bread sync diff --remote

# List known machines
bread sync machines

Configure sync in ~/.config/bread/sync.toml:

[remote]
url = "git@github.com:you/bread-config.git"
branch = "main"

[machine]
name = "hermes"
tags = ["laptop", "battery"]

[packages]
enabled = true
managers = ["pacman", "pip", "cargo"]

[delegates]
include = ["~/.config/nvim", "~/.config/waybar"]
exclude = ["**/.git", "**/*.cache"]

The sync repo stores:

~/.local/share/bread/sync-repo/
├── bread/          ← ~/.config/bread/ snapshot
├── configs/        ← delegate paths (nvim, waybar, etc.)
├── machines/       ← per-machine profiles with tags and last-sync time
└── packages/       ← package snapshots (pacman.txt, pip.txt, etc.)

Debugging tips

  • Run bread events to see live normalized events.
  • Run bread state to see full runtime state as JSON.
  • Run bread doctor to check adapter and module health.
  • 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.

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 machine name from sync.toml. Falls back to the system hostname if sync is not initialized.

bread.machine.tags() -> string[]

Returns the tags array from sync.toml, or {} if sync is not initialized.

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)

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 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": {}
}

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)

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.

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
sync.status Return sync init state: { initialized, machine?, remote? }