
Waitbus
io.github.astrogildaPublished by GitHub account astrogildav0.2.0Updated Oct 25, 2026
Workstation-local event bus: agents wait on or emit CI/pytest/Docker/filesystem events, no polling.
Overview
A workstation-local event bus MCP server that lets an assistant wait on, query and emit CI, pytest, Docker, filesystem and alert events without polling.
- What you can do
- Query status and recent events stored in the local SQLite event store, and roll job events into per-PR state via the pull tools and CLI commands described in the README (read-events list, pr-monitor tick). Block-wait for matching events across sources, including combined all-of/first-of predicates, with a bounded long-poll tail instead of a polling loop. Emit events onto the bus so other local agents, scripts and CI jobs wake up. Receive push notifications for resource subscribers, plus an inline channel notification for Claude Code. Run a command the moment a predicate matches, with event values passed as environment variables rather than arguments.
- When to use it
- Useful when several agents, scripts, tests or containers run on one machine and need to hear each other's finishes and failures, or when a script must block until tests, a build, CI and a container exit are all done. Not worth it for waiting on a single GitHub repo's CI or plain file changes, where simpler tools suffice.
- Requirements
- Runs as a local stdio process, typically launched with uvx, so uv must be on PATH and Python 3.11+ is needed. Linux (systemd-user) or macOS (launchd); desktop only. A broadcast daemon and optional source watchers must be running for events to arrive. Optional HMAC secrets for the GitHub webhook listener and Alertmanager are staged via install-credentials; no account or cloud service is used.
Installation
In SourceWeft
- Open Waitbus in the dashboard and add it to a workspace.
- Enable the server for the chats that should use its tools.
Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.
Other MCP clients
Follow the launch instructions in the repository.
README
waitbus: the workstation-local, cross-harness status bus. D-Bus for agents, with a replay log and a wait primitive
[CI] [Python 3.11+] [License: MIT] [Ask DeepWiki] [Context7]
One wait verb that blocks on, and queries across, every local source at once. When any agent, CI job, test, or container on your machine finishes or fails, every other tool on the box hears it: your Claude Code, your Cursor, your scripts, your CI. Clients that support server notifications get a push; the rest get one blocking waitbus wait. No waitbus cloud, no account, no telemetry; all processing stays on your machine.
Below: a Pydantic AI agent and a LangGraph agent, two different frameworks running as two separate OS processes, both wait on one local waitbus bus. One fails; the peer on the other framework, plus a live waitbus top view, react to the single failure broadcast.
Real frameworks and real waitbus subscribe/emit; the agents' LLMs are deterministic fakes and the failure is an injected event, so the clip runs fully offline. Reproduce the claim (not just the recording): run uvx waitbus demo for the gentler single-agent walk-through (no clone, no install), or, for the exact two-framework proof shown above, clone the repo and run uv run --group agent-recipes python -m examples.hero_swarm (Linux-verified; prints PROVEN: two DIFFERENT frameworks woke on ONE peer's failure). Higher-quality MP4 at hero.mp4. Maintainers re-render this recording from docs/demo/.waitbus-demo/ with make hero (dev-only; needs vhs, ttyd, tmux, and JetBrains Mono).
Try it now, no install:
uvx waitbus demoboots a real local bus and returns the instant an event lands, fully offline (~8 s).
waitbus is the workstation-local, cross-harness status bus that lets the tools on one machine hear each other. It does three things: ingest events, broadcast them, and replay them.
Ingest: a blocking wait/emit primitive lets any agent or script
(across Cursor, Claude Code, and any tool on the box) wait on, or emit, events
from five built-in sources (GitHub Actions CI, pytest sessions, Docker
container lifecycle, filesystem changes, and Prometheus Alertmanager) plus any
plugin source registered under the waitbus.sources.v1 entry-point group.
Broadcast: events land in SQLite the moment they arrive, and a broadcast
daemon fans each row to every connected consumer within about a
millisecond on the local socket (measured; see benchmarks/BENCHMARKING.md). Your
agent blocks on waitbus wait (including across sources at once with
--all-of/--first-of) with zero agent-side polling and idle CPU until the thing it
cares about happens, and waitbus on <predicate> -- <command> runs a command
the moment it does (in the CLI process, not the daemon, via execve with no
shell; event values arrive as namespaced WAITBUS_* environment variables and
a $WAITBUS_EVENT_FILE, never as argv, so event content cannot inject flags
or overwrite PATH). Because every agent on the box shares that one bus, it
doubles as a same-machine coordination backplane: one agent emits, the others
wake.
Replay: a durable replay log (since=) means a consumer that was
offline catches up instead of missing the moment, and waitbus events analyze
queries what the bus stored. It is the local opt-in
event-broadcast model the OS already uses (D-Bus signals, inotify, journald),
with the replay log and a wait predicate added on top. (GitHub Actions was
the first source wired, which is why the examples lead with CI, but waitbus is
not CI-only.) The dependency stack is lean: typer, msgspec, platformdirs,
pydantic-settings, prometheus_client, stamina, and the mcp SDK, with no
heavy TUI or crypto dependency. The daemon idles at roughly 40 MB RSS measured
(p50 on the benchmark host, most of it the CPython interpreter baseline) over
a SQLite event store and an AF_UNIX SOCK_STREAM broadcast bus with
length-prefix framing. Runs on Linux (systemd-user) and macOS (launchd).
Who this is for
This is for you if…
- You run a heterogeneous agent fleet (Claude Code and Cursor and background scripts) on one workstation and want them to hear each other's finishes and failures.
- You wait across sources. "Block until the tests pass and the build is
done and CI is green," in one predicate, in a script
(
waitbus wait --all-of ...). Nothing else expresses this. - You want zero-LLM-token, zero-poll waiting as a daily habit across many local things, and you'll run a daemon to get it.
- You care that the daemon on your box is trustworthy: reproducible builds, offline, no cloud, no account, no telemetry.
This is NOT for you if…
- You wait on one GitHub repo's CI and nothing else. Use
gh run watch. It's zero-setup, and for that single job we don't beat it by enough to justify a daemon. - You only need to react to a file change. Use
inotifywait/entr. They're far faster on raw filesystem latency (our own benchmark says so) and need no daemon. - You want cloud, cross-machine, or team coordination. Out of scope for the local core by design.
- You want addressed agent-to-agent messaging as the product (threads,
inboxes, routing). The headline here is broadcast source-ingestion; the
request()/respond()facet exists, but a dedicated message queue serves that center of gravity better.
Try it in 60 seconds
waitbus demo allocates a temporary state directory and boots the
broadcast daemon, then runs two phases. Phase 1, the point: an
agent blocks on waitbus wait (the same egress engine the real command
uses) with zero polling and idle CPU; the moment a github
workflow_run event lands, the wait returns and the demo prints the
real measured event-to-unblock latency. Phase 2, breadth: the same
primitive delivers every source. pytest_session, docker_container,
and fs_change events fan out to a live subscriber. Nothing on your
machine outside the temporary directory is touched.
A recorded MP4 + GIF walkthrough lives at
docs/demo/.waitbus-demo/demo.mp4 and demo.gif
(the tape script is demo.tape). Re-rendering is reproducible
via make demo from that directory (requires
VHS ≥ 0.10.0, ttyd, and
ffmpeg); the Makefile enforces the VHS version floor and refuses
to render against an older binary that could silently change tape
semantics.
The four events the demo emits are synthesized in-process. There is no real HTTP listener, no real pytest run, no real Docker daemon, no real watchdog. A banner before each emit makes this explicit, mirroring the
waitbus statsoutput. To wire waitbus against real GitHub webhook deliveries, follow the Quick start below.
Quick start
Prove the primitive in ~10 seconds -- one foreground daemon, one event, no service install. Install the CLI and bootstrap state:
Then, in two terminals, watch a blocking wait return the instant an emit
lands on the bus:
That round trip -- block, emit, unblock, with zero polling -- is the whole primitive. Everything below wires it to real sources and, optionally, runs the daemons permanently under your service manager.
Run the fleet permanently (optional)
install-systemd is Linux-only; install-launchd is macOS-only.
Each command refuses to run on the other platform and points at the
right one.
Once events are flowing, block any script or agent on the next
matching event with waitbus wait -- any source, any field, exit code
carries the verdict. The wait side is always zero-setup; the event it
waits for still has to be produced by something (an opt-in emitter or a source
watcher), so each example below notes what must be emitting for it to return:
If you have used watchexec or entr to
re-run a command when a file changes, waitbus wait is the same idea with a wider
net: it blocks on a file change too, but it can also wait on a CI run, a pytest
session, a container exit, or another agent's event, and hands the verdict back
through the exit code. The bus underneath also
fans every one of those events out to every other tool on the box.
Architecture
Rendered from docs/architecture.mmd (a static image is used so the diagram renders on PyPI and other non-Mermaid surfaces, not only on GitHub). Regenerate with mmdc -i docs/architecture.mmd -o docs/architecture.png.
Two ingress classes land events in the same store: remote webhooks (GitHub,
Alertmanager) arrive over HTTPS and pass through waitbus listener serve, while
in-process sources (pytest, docker, fs, plugins, and any agent) call the
public emit() API to write the row directly, with no listener and no network hop.
An ETag polling fallback runs on a 45-second timer for repos you cannot receive
webhooks for (upstreams, forks). Every path writes through the same
INSERT OR IGNORE, so redeliveries are idempotent, and every row rings the
broadcast doorbell so subscribers wake within about a millisecond regardless of which
ingress produced it.
How waitbus relates to MCP Tasks (task=True) and to the local agent-to-agent
buses (agent-message-queue, claude-code-inter-session): see
docs/COMPETITIVE_LANDSCAPE.md.
Installation
As a Python CLI tool
Each command is idempotent. Re-running install-credentials <name> rotates
the credential (re-configure the GitHub webhook and Alertmanager after
rotating, then systemctl --user restart waitbus-listener.service).
After install you have one console script on PATH: waitbus. All
daemon entry-points and admin commands are reachable via sub-commands:
And, depending on platform, either 8 systemd-user units in ~/.config/systemd/user/ (Linux):
…or 4 LaunchAgent plists in ~/Library/LaunchAgents/ (macOS):
The macOS plists carry __BIN_DIR__ / __LOG_DIR__ / __RUNTIME_DIR__
placeholders that waitbus install-launchd resolves to the
operator's actual paths before writing. Agents are loaded via
launchctl bootstrap gui/$UID <plist> (the modern replacement for
the deprecated launchctl load -w).
As a Python library
Agents can also message each other on the same bus -- one sends a request and blocks for the correlated reply, the other answers from its inbox:
See docs/AGENT_MESSAGING.md for the request/reply
contract, the inbox stream, and the same-UID trust model.
The stable public API is emit, subscribe, asubscribe, wait_for,
request, respond, EventFrame, and the plugin hooks register_source,
register_condition, register_evaluator (full signatures in
docs/CONSUMER_API.md). To produce events, the
waitbus emit CLI is the simplest path; see examples/emitters/ for the
emit() Python form. Everything under a leading underscore (waitbus._db,
waitbus._paths, …) is a private internal and may change between releases.
Runtime dependencies (the full mandatory set, same as the lean stack
above): typer (umbrella CLI), msgspec (wire framing), platformdirs
(state and runtime path resolution), pydantic-settings (config
loader), prometheus_client (metrics), stamina (retry policy), and
the mcp SDK (MCP server). The two consumer-facing extras
(waitbus[analyze], waitbus[fs]) pull duckdb / watchdog lazily and
are the only optional packages. All other internal modules are pure
stdlib: no network at import time, no C extensions, safe to import
in any Python 3.11+ environment. The package root logger uses
NullHandler so library consumers never see leaked log records.
As an MCP server (PyPI + uvx)
waitbus mcp serve is a stdio MCP server (JSON-RPC 2.0 over the spec at
https://modelcontextprotocol.io/specification). What a given client can do with it
depends on which MCP features that client implements, so here is the actual
capability split:
- Pull, for any MCP client that supports Tools. Call waitbus tools to query
status and to block-wait for events;
tail_eventsis a bounded long-poll, so even waiting needs no polling loop. This is the broadly portable path. - Push, for clients that support resource subscription. waitbus emits the
spec-standard
notifications/resources/updatedforwaitbus://repo/...subscribers. Whether your client surfaces that push (rather than only calling tools) depends on the client; most coding-agent MCP clients today are pull-only, and the published client matrices track Tools / Resources / Prompts, not a notification-surfacing column. - Inline render, for Claude Code. waitbus also emits an Anthropic-private
notifications/claude/channelthat Claude Code renders inline; other clients ignore the unknown method harmlessly per JSON-RPC 2.0.
Verified with: an executable test exercises the path end-to-end, not just
"speaks the spec": Claude Code (the MCP emit path, tests/test_mcp_e2e.py);
and, via the public Python SDK (waitbus.wait_for / subscribe), a
Pydantic AI and a LangGraph agent (tests/test_agent_integration_*.py);
plus the language snippets and the bash waitbus wait wrapper. Any other
stdio-MCP client can use the pull path above, but that is config, not a tested
end-to-end guarantee.
The command is always uvx --from waitbus waitbus mcp serve; only WHERE each client
stores its MCP config differs. Those locations and formats move across client
versions, so treat the table below as a starting point and check your client's
current MCP docs:
Aider is intentionally absent: as of 2026-05 Aider ships no native MCP client (tracked upstream in aider-AI/aider FR #4506; the only MCP path is the separate AiderDesk companion app). Point Aider-style or non-MCP agents at waitbus through the Python SDK (
waitbus.wait_for) or thewaitbus waitCLI instead.
uv must be on PATH (curl -LsSf https://astral.sh/uv/install.sh | sh).
See platform support.
As a Claude Code plugin
Until a marketplace / MCP-registry entry is published, load the plugin from a
local checkout of this repository. The manifest at .claude-plugin/plugin.json
references the repo-root .mcp.json, so --plugin-dir must point at the
checkout root (not a subdirectory):
--dangerously-load-development-channels is required to load a plugin from a
local directory that is not a published marketplace entry (the same invocation
documented in CONTRIBUTING.md). Once the marketplace
entry is published to the MCP Registry (see CHANGELOG.md for current status),
install it from there instead. Either way the plugin enables /waitbus slash
commands and the waitbus skill inside any Claude Code session.
Usage
Reading events
Key features
-
Sub-second
workflow_jobfailure detection. Catches matrix-cell failures within sub-second of GitHub delivering the webhook (wall-clock from webhook receipt, not from the CI failure itself), with no waiting for the parentworkflow_run.completedto arrive. Individual job failures surface immediately even when other matrix cells are still running. -
SQLite event store with idempotent writes.
INSERT OR IGNOREonevent_id(ULID, assigned at ingest time). Webhook redeliveries and ETag-poll duplicates are silently discarded; the DB stays consistent without a dedup queue. -
AF_UNIX
SOCK_STREAMbroadcast bus with length-prefix framing. Each wire frame is a 4-byte big-endian length followed by the JSON payload, portable across Linux and macOS (whereSOCK_SEQPACKETis unavailable). Non-blocking sockets with per-subscriber lag counters; slow subscribers are closed afterLAG_LIMITconsecutive drops and reconnect via EOF-triggered cursor reset. -
HMAC-SHA256 webhook signature verification. Secret staged once via
waitbus install-credentials github-webhook-secretinto a0600secrets.jsonunder the user state directory, read at unit-start. Missing or invalid signatures are rejected with 401 before the payload is read. -
SIGTERM drain guarantee. The broadcast daemon stops accepting new subscribers on SIGTERM, drains all in-flight frames to connected subscribers, closes them with EOF, unlinks its socket files, and exits 0. No events are dropped on
systemctl restart waitbus-broadcast. -
ETag polling fallback. A 45-second timer polls repos listed in
watched_repos.txtusingIf-None-Matchto avoid burning GitHub API rate limits. Coexists with webhook-driven repos. -
Prometheus metrics endpoint.
http://127.0.0.1:9000/metricsexposes ingestion-event and ingestion-error counters in Prometheus text format. The watchdog timer detects and records ingestion silence.
Configuration
Config file (optional): ~/.config/waitbus/config.toml
Environment variable overrides:
State directory resolution
Path resolution lives in waitbus._paths and follows the same
first-hit-wins precedence as the rest of the config chain:
WAITBUS_STATE_DIR/WAITBUS_RUNTIME_DIRenv vars (operator-controlled).platformdirs.PlatformDirs("waitbus")per-platform defaults:- Linux: state at
~/.local/state/waitbus/(honoursXDG_STATE_HOME); sockets at/run/user/$UID/waitbus/(honoursXDG_RUNTIME_DIR). - macOS: state at
~/Library/Application Support/waitbus/; sockets at a stabletempfile.gettempdir()-derived path (platformdirs' macOSuser_runtime_diris Apple-evictable cache, unsuitable for AF_UNIX sockets).
- Linux: state at
The shipped systemd units use StateDirectory=waitbus and
RuntimeDirectory=waitbus, so on Linux systemd creates the
directories with 0700 ownership before the daemons start, so no
operator mkdir is required.
Secrets (staged via waitbus install-credentials <name>):
The broadcast bus itself needs no secret: it is an AF_UNIX socket guarded by
the kernel's same-UID peer-credential check, so there is no subscriber token.
Secrets are stored in a single 0600-mode secrets.json under the user
state directory (<state_dir>/secrets.json); install-credentials reads the
value from --file or stdin and merges it in with an atomic replace, never
exposing it on the command line. At-rest protection of the file is delegated
to the host's full-disk encryption (FileVault / LUKS); see
SECURITY.md. waitbus doctor reports whether each required
key is present without printing its value, and exits 1 if a piece needed by
an enabled unit is missing.
Security
See SECURITY.md for the full HMAC threat model, platform assumptions, and vulnerability reporting process.
Same-UID trust model. The broadcast bus has no inter-process
confidentiality: any process running as your user can read every event on the
bus and can emit events under any sender name. waitbus is a single-user
workstation tool, not a security boundary between processes you run. Peer
authentication is by kernel-verified UID (SO_PEERCRED), so a different user
cannot connect, but same-user isolation is out of scope by design.
To verify the release artifacts (requires the gh CLI, network access, and a
published GitHub release that carries attestations -- so this is not a
run-it-offline-in-a-clone check):
See SECURITY.md for the full provenance verification procedure including SLSA, PEP 740, and SBOM checks.
Support
For bug reports, feature requests, and questions, see SUPPORT.md; it lists the security, abuse/conduct, and general channels and sets single-maintainer, best-effort expectations. Security vulnerabilities go through the private process in SECURITY.md, never a public issue.
Operating
Enable units to start automatically on login:
Rotate the webhook HMAC secret:
Headless server (SSH-only / no graphical session)
On a headless box without a graphical login session, two additional pieces are required for the systemd-user instance to run continuously even when no interactive session is open:
-
Keep the user manager alive across logouts. Run once:
Without this, the systemd user manager and all its units stop when the last interactive session ends.
-
No keyring unlock required. Secrets live in a
0600secrets.jsonunder the user state directory, read with a plain file read, no keyring, no D-Bus session requirement, and nopam_gnome_keyring.sodependency, so the daemon stack runs unmodified on a fully headless box.
Verifying the linger setting works. Open a fresh SSH session (do NOT start the units manually first) and run:
The result should be active. If the unit is not active without a manual
systemctl --user start, the linger setting is not in effect.
XDG_RUNTIME_DIR on linger-enabled hosts. When linger is enabled,
systemd sets XDG_RUNTIME_DIR=/run/user/$(id -u) automatically and creates
the broadcast socket path beneath it. No operator action is required; the
path is available from the first systemctl --user call in any session.
Wiring up repositories
Local sources need no per-repo wiring. pytest, Docker, and filesystem
events are produced on the box itself, so there is no webhook forwarder to set
up. Once the daemons are running (Quick start above) and the relevant producer
is emitting -- the pytest opt-in emitter, or the waitbus source docker /
waitbus source fs watchers -- waitbus wait --source pytest|docker|fs needs
zero per-repo configuration. The two subsections below are only for GitHub CI,
which arrives over a webhook and therefore needs a forwarder.
Event-driven (preferred for repos you own):
gh webhook forward creates an ephemeral GitHub-side webhook and tunnels
deliveries to the local listener. The secret must match the plaintext you
staged via waitbus install-credentials github-webhook-secret.
For a systemd-supervised forwarder (the shipped [email protected]
template), no operator action is needed: the unit reads the
github-webhook-secret key from secrets.json (via $STATE_DIRECTORY) at
start and passes it to gh webhook forward --secret.
ETag polling (repos you can read but cannot receive webhooks for):
Append owner/repo lines to watched_repos.txt under the resolved
state directory (waitbus doctor prints the path; on Linux without
WAITBUS_STATE_DIR overrides, that is ~/.local/state/waitbus/watched_repos.txt).
The 45-second timer picks them up automatically.
For SSH-only boxes, see Headless server
for the loginctl enable-linger requirement.
Platform support
Both platforms run the full daemon stack. Linux uses systemd-user units
with socket activation; macOS uses launchd LaunchAgents with manual
socket bind. The peer-credential UID check, doorbell primitive, and
install entry-point each dispatch inline at the leaf call site; there
is no IPCBackend-style abstraction layer, since those three leaf
sites are the only platform-divergent code.
Details in SECURITY.md.
Native Windows is not supported. waitbus's trust model rests on an
AF_UNIX socket plus a SO_PEERCRED same-UID peer check, which has no
Win32 equivalent. Windows users run waitbus under WSL2, which is a real
Linux kernel: pip install waitbus (or uv tool install waitbus) inside a
WSL2 distribution works exactly as it does on native Linux. See
docs/ARCHITECTURE.md for the platform rationale.
Zero default egress, no telemetry. The broadcast daemon, every
subscriber (waitbus wait, waitbus read-events, waitbus mcp serve), and
the emit() path make no outbound network calls; nothing is phoned
home. The only network surface is the optional inbound GitHub webhook
listener (loopback 127.0.0.1:9000, HMAC-verified), plus local OS keyring
access for credential storage. Outbound traffic happens only when you
explicitly opt into the ETag polling fallback (which calls the GitHub API
for repos you list). See SECURITY.md for the full network
surface.
Contributing
See .github/CONTRIBUTING.md for the per-surface dev guide, loading the plugin locally, version sync, and release flow.
License
waitbus is released under the MIT License. See LICENSE for the full text.
More
- CHANGELOG.md: version history and migration notes
- SECURITY.md: HMAC threat model, platform assumptions, reporting process
- .github/CONTRIBUTING.md: per-surface dev guide, loading the plugin locally
- docs/emitters/recipes.md: shell and docker emit recipes, plus the CloudEvents envelope mapping for external producers
- docs/emitters/claude-code-hook.md: emit a bus event on Claude Code session lifecycle hooks
Source: README.md at commit 12482fc
Tools
0Version history
1- v0.2.0LatestSep 16, 2026
Score
50% of the applicable checks met
How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.
- Reached in the last 30 days25 pts
Keep the endpoint answering the MCP handshake; SourceWeft probes it every 30 days.
- Auth declared10 pts
Declare the credential headers in the registry entry, or keep the endpoint answering the probe so its auth can be classified.
- Tools listed15 pts
Answer tools/list without a credential, or ship a package that starts in a sandbox without one.
- DescriptionRequired15 pts
- Logo10 pts
- Documentation15 pts
- License10 pts