agentbus
by selimam2
README.md
# AgentBus
**A durable coordination bus for AI agent fleets — with a human approval gate in front of anything irreversible.**
Agents are increasingly allowed to deploy, send, delete, and spend. Two things
break at that point, and neither is an LLM problem:
1. **Handoffs get lost.** An agent finishes, hands work to the next one, and
the process dies. Nothing retries, nobody notices.
2. **There is no choke point.** Approval lives inside a prompt — "ask me
first" — which is a suggestion, not a control, and leaves no record of who
allowed what.
AgentBus is the boring infrastructure underneath: a durable queue with
at-least-once delivery, a blocking human-approval gate, and an append-only
audit log of every message and decision.
```
┌──────────────────────────────┐
agents ────────▶│ topics · groups · leases │────────▶ workers
(MCP/HTTP/CLI) │ retries · dead letters │
├──────────────────────────────┤
agent ─ ask ───▶│ approval gate ── blocks ───│──▶ human (phone/dashboard)
├──────────────────────────────┤
│ append-only audit log │
└──────────────────────────────┘
one SQLite file
```
Zero runtime dependencies. No Redis, no Postgres, no broker, no cloud account.
Node 24+ and a file.
---
## Quick start
```bash
npx agentbus serve # http://127.0.0.1:7801 — API + dashboard
```
### Gate a risky action behind a human
```bash
agentbus ask "Deploy api build 118 to production" \
--action deploy.prod --risk high \
--detail "3 commits, including a migration that drops a column." \
--payload '{"service":"api","build":118}' \
--wait && ./deploy.sh
```
`ask --wait` blocks and prints a link. A person opens it on their phone,
approves or denies, and the command exits **0 approved · 10 denied · 11
expired** — so `&&` does exactly the right thing, and the decision is recorded
against their name.
### Durable work handoff
```bash
# Producer — survives a crash, deduplicated by key
agentbus pub job.render '{"file":"a.png"}' --key render-a
# Consumer — any shell command becomes a durable worker.
# Payload on stdin; exit 0 acks, non-zero retries with backoff, then dead-letters.
agentbus sub 'job.>' --group renderers --exec './render.sh'
```
Unacked work returns to the queue when the lease expires, so a worker that gets
killed mid-job loses nothing.
---
## Giving an agent access (MCP)
```jsonc
// .mcp.json — or claude_desktop_config.json
{
"mcpServers": {
"agentbus": {
"command": "npx",
"args": ["-y", "agentbus", "mcp"],
"env": { "AGENTBUS_URL": "http://127.0.0.1:7801", "AGENTBUS_AGENT": "deploy-agent" }
}
}
}
```
The agent gets `request_approval`, `check_approval`, `publish`, `pull`, `ack`,
`nack`, and `stats`. `request_approval` **blocks** until a human decides, and
returns explicit guidance:
```json
{
"state": "denied",
"approved": false,
"decidedBy": "sami",
"reason": "not without a backup",
"guidance": "Not approved (denied). Do NOT proceed. Tell the user and stop."
}
```
A timeout returns `pending`, never `approved` — silence is never consent.
---
## Concepts
**Topics** are dot-separated: `deploy.prod.api`. Patterns use `*` for one
segment and `>` for the rest — `deploy.>`, `*.prod.api`, `>`.
**Consumer groups** each get their own copy of every matching message. Within a
group, a message goes to exactly one worker at a time. A new group starts from
*now* by default; `--earliest` replays the whole backlog.
**Leases** give a worker a visibility timeout (30s default, extendable). Miss
it and the message is redelivered. After `max_attempts` (5) it is
dead-lettered, visible on the dashboard, and replayable with one click.
**Approvals** carry a title, machine-readable action, free-text detail,
structured payload, risk level, and optional labelled choices ("canary 10%" vs
"full rollout"). They expire rather than hang forever. Every request and
decision — who, when, why — lands in the audit log.
**Ordering** is by a SQLite sequence, not by timestamp: two messages published
in the same millisecond still have a well-defined order.
---
## HTTP API
| Method | Path | |
|---|---|---|
| `POST` | `/v1/publish` | `{topic, payload, idempotencyKey?, delayMs?}` |
| `POST` | `/v1/pull` | `{group, patterns?, max?, leaseMs?, waitMs?}` — long-polls |
| `POST` | `/v1/ack` · `/v1/nack` · `/v1/extend` | settle or extend a delivery |
| `GET` | `/v1/stats` · `/v1/dead` · `/v1/audit` | observability |
| `POST` | `/v1/replay` | requeue a dead letter |
| `POST` | `/v1/approvals` | create; returns a signed shareable URL |
| `GET` | `/v1/approvals/:id/wait` | long-poll until decided |
| `POST` | `/v1/approvals/:id/decide` | `{decision, by, reason?, choice?}` |
| `GET` | `/v1/stream` | SSE tail of messages and approvals |
| `GET` | `/` · `/a/:id` | dashboard · single-approval page |
Set `AGENTBUS_TOKEN` to require `Authorization: Bearer …`. Approval links carry
their own HMAC token, so you can send one to a phone without handing over the
API key.
**Environment:** `AGENTBUS_URL`, `AGENTBUS_TOKEN`, `AGENTBUS_DB`,
`AGENTBUS_SECRET`, `AGENTBUS_AGENT`, `AGENTBUS_ALLOWED_ORIGINS`.
### Browser-origin policy
The default is an unauthenticated server on loopback, which means a webpage you
happen to have open would otherwise be able to reach it. Three rules stop that,
and they apply whether or not a token is set:
- A request carrying an `Origin` header is refused unless that origin is the
server's own, the host in `AGENTBUS_URL`, or listed in
`AGENTBUS_ALLOWED_ORIGINS`. Refused means **403** — not merely a missing CORS
header, which hides the response but still performs the write.
- `POST` requires `content-type: application/json`. Form and `text/plain`
bodies are CORS "simple requests" that a browser sends with no preflight at
all, so accepting them would reopen the hole.
- An unexpected `Host` header is refused, which blocks DNS rebinding.
Non-browser clients — the CLI, the MCP server, curl, your own scripts — send no
`Origin` and are unaffected. If you serve the dashboard on a LAN address or
behind a proxy so approvals reach a phone, set `AGENTBUS_URL` (or `--url`) to
that public origin; it is what approval links are built from anyway.
---
## Development
```bash
npm test # 66 tests, no network, no fixtures
npm run typecheck # strict, noUncheckedIndexedAccess, erasableSyntaxOnly
```
TypeScript runs directly on Node 24 via native type stripping — there is no
build step and no compiler in the runtime path.
---
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues