chatroom-mcp
README.md
# ChatRoomMCP
A small coordination server for [Claude Code](https://claude.com/claude-code) agents
running on separate machines. Give a team of agents one shared room instead of a
directory of files or a chat log they have to remember to check.
Two surfaces share one room:
- **Chat** — `post_message` / `read_messages`: announcements, questions, and discussion
that aren't work items ("the poller is live, you can retire the old sensors").
- **Board** — tasks with **atomic ownership** and optimistic-concurrency updates, for work
that must be claimed, tracked, and handed off ("please host the poller" → claim → done).
Exactly one agent can win a contended task — the thing a shared file/git directory can't do.
An included **hook** injects unread peer activity into each agent's context automatically,
so coordination happens whether or not the model thinks to poll. Wire it to
`UserPromptSubmit` for delivery when a human prompts, and/or `PostToolUse` for delivery
*during* a long autonomous run — the in-loop path is throttled to one check a minute and
stays silent unless something actually happened.
An included **watcher** ([`hooks/chatroom_watch.py`](hooks/chatroom_watch.py)) covers what a
hook structurally cannot: an agent parked at the prompt runs no hooks at all, so it stays
deaf until its human returns. The watcher holds the server's SSE stream open and prints one
line per notification, which Claude Code's `Monitor` tool turns into a wake-up — including
while the agent is idle. See [Push delivery](#push-delivery-the-watcher).
Built on the official Python MCP SDK (`mcp` 2.0.0), served over streamable HTTP in stateless
JSON-response mode — every tool call is a self-contained POST, so it sits behind any proxy
and is debuggable with `curl`. Storage is a single SQLite file.
## Quick start
See **[GETTING_STARTED.md](GETTING_STARTED.md)** for step-by-step server and client setup.
The short version:
```bash
# Server (once)
cp .env.example .env && $EDITOR .env # set CHATROOM_ALLOWED_HOSTS to your host
docker compose up -d
docker compose exec chatroom python -m chatroom.admin init
docker compose exec chatroom python -m chatroom.admin add-room ops
docker compose exec chatroom python -m chatroom.admin add-token --agent box1 --room ops
# Client (one token per agent SESSION, not per machine — see note below)
claude mcp add --scope user --transport http chatroom \
http://<server-host>:<port>/mcp --header "Authorization: Bearer <token>"
```
Dashboard: `http://<server-host>:<port>/ui` (paste a read-only observer token).
For agents on machines outside your network, **[CLOUDFLARE_TUNNEL.md](CLOUDFLARE_TUNNEL.md)**
publishes the server on a hostname you own with no inbound port and no router changes, on
Cloudflare's free plan.
## Tools exposed to agents
| Tool | Purpose |
|---|---|
| `post_message(body, reply_to)` | Chat: announcements & discussion. Threads via `reply_to`. |
| `read_messages(since_id, limit)` | Full chat bodies (side-effect free). |
| `read_events(since_id, limit, kind, task_id)` | Event history **without consuming it** — the sequence that produced current state. Never advances your cursor. |
| `whats_new()` | Chat + board events since your cursor; advances it. Surfaces room onboarding on first look. Call first *unless the hook is installed* — it shares this cursor and will have consumed it already. |
| `list_tasks(status, assignee, limit)` | Board state. `status="open"`, `assignee="me"`. |
| `get_task(task_id)` | One task plus all notes. |
| `create_task(title, body, depends_on, claim)` | Add work. |
| `claim_task(task_id)` | Atomic ownership. Fails if a peer holds it. |
| `update_task(task_id, status, body, note, expected_version)` | Mutate with conflict detection. |
| `release_task(task_id, reason)` | Hand work back. |
| `add_note(task_id, body)` | Discussion scoped to a task. |
| `put_file(name, content_base64, mime, expires_in_hours)` | Share a small file (source/config; ~1 MB cap). `expires_in_hours` makes a scratch artefact clean itself up. |
| `delete_file(file_id)` | Remove a file. Its author, or any admin token. |
| `get_file(file_id)` / `list_files()` | Fetch a file's bytes / list room files (also `GET /v1/files/<id>`). |
| `get_room_info()` / `set_room_info(description, repo_url, onboarding_notes)` | Read/set a room's standing context for newcomers. |
| `set_retention(days)` *(admin)* / `delete_room(room)` *(admin)* | Prune old chat/events/files; delete a room. |
| `wait_for_change(timeout_s)` | Long poll while blocked on a peer. |
| `list_agents()` | Roster and last-seen for your room. |
Task statuses: `pending`, `in_progress`, `blocked`, `done`, `cancelled`. Identity and room
come from the caller's token — never a tool argument a model can spoof.
**Token roles:** read-write (default), `--readonly` observer, `--admin` (retention/room
deletion), `--all-rooms` (a whole-instance dashboard/observer that can browse every room).
**MQTT bridge (optional):** set `CHATROOM_MQTT_HOST` and every room event is published to
`<prefix>/<room>/<kind>` as JSON — so a home-automation stack (or anything on the broker)
can react to agent activity (task created, message posted, file shared, …).
## Admin console
`/admin` is a browser console for the maintenance work that otherwise needs a shell on the
server: create rooms, set retention and onboarding notes, mint tokens, revoke agents, and read
the instance's current posture. It requires a **whole-server admin** token — minted with both
`--admin` and `--all-rooms`; a room-scoped admin token is refused.
**The consoles are LAN-only.** `/ui` and `/admin` refuse any request that arrived from the
public side — detected by edge headers (`CF-Ray`, `CF-Connecting-IP`) or a Host listed in
`CHATROOM_PUBLIC_HOSTS` / `CHATROOM_PUBLIC_URL` — and return `404`. A browser cannot send a
bearer token on its initial page load, so unlike the API these surfaces cannot be
credential-gated; keeping them off the public route is the protection. An edge WAF rule can do
the same thing, but it lives in someone else's dashboard, so the server enforces it too.
Because of that, minting emits setup text for **both routes**, and you pick per machine:
- **LAN** (preferred, shown first) — shorter path, no tunnel bandwidth, no dependency on an
external service staying up
- **Remote** — only for machines that cannot reach the LAN address, from `CHATROOM_PUBLIC_URL`
The public URL is *configured*, never inferred: the admin is by definition on the LAN, so
their request can never reveal the tunnel hostname. Each route gets:
- the `claude mcp add …` line, and the equivalent `.mcp.json`
- a hook install block that **fetches the hook from the server** (`GET /v1/hook`) rather
than assuming a checkout, and merges it into `~/.claude/settings.json` idempotently
- a **paste-to-agent brief** stating the room, the agent's identity and role, and the
untrusted-data rule — so a new agent can wire itself up and understand the room
- the equivalent `admin add-token` command, for your records
Revoked tokens are hidden from the list by default (with the count shown) and can be
**purged** — permanently deleting those rows, optionally only ones revoked more than N
days ago. Revocation is reversible-ish in that the record survives; purging is not, so
it is a separate deliberate action. Live tokens can never be removed by it.
**It is off by default: `CHATROOM_ADMIN_API=on`.** That is deliberate. Without it, an admin
token can prune and delete rooms; with it, that same token can mint credentials for any room —
including another admin — from anywhere it can reach the server. Minting has always required
shell access on the host, and that is a real boundary, so turning it into an HTTP surface
should be a decision rather than a default. Every mutation is logged with the acting agent and
its address, and creating a new whole-server admin is flagged in that log. Tokens are still
shown exactly once and stored only as SHA-256 — the raw value is never logged.
If the server is internet-reachable, weigh this against
[CLOUDFLARE_TUNNEL.md § Why no Access](CLOUDFLARE_TUNNEL.md#why-no-access-and-what-that-costs-you):
with no identity layer in front, a leaked admin token plus this console is full control of the
instance. Leaving it off and provisioning from the host CLI is a perfectly good choice.
## Push delivery: the watcher
The hook is *pull*: it can only run when the agent runs. That leaves two gaps — an agent
inside one long tool call learns nothing until it returns, and an agent parked at the prompt
runs no hooks at all. `hooks/chatroom_watch.py` closes them by holding `/v1/stream` open and
printing one line per notification, which Claude Code's `Monitor` tool turns into a wake-up.
```bash
# install (from any box with a token — no clone needed)
curl -fsSL -H "Authorization: Bearer $CHATROOM_TOKEN" \
"$CHATROOM_URL/v1/watch" -o ~/.claude/hooks/chatroom_watch.py
python3 ~/.claude/hooks/chatroom_watch.py --selfcheck # version, digest, settings
```
The agent then arms it once per session:
```text
Monitor(command="python3 ~/.claude/hooks/chatroom_watch.py",
description="chatroom <room>", persistent=true)
```
**Every line printed costs a model turn**, so what it does *not* print is the whole design.
| Mode | Prints |
| --- | --- |
| `hook-only` | nothing. At launch it exits rather than hold a connection; set at runtime it mutes a running watcher so it can be unmuted |
| `mentions` | only when this agent is named by someone else *(default)* |
| `all` | every chat message and board event |
In `all`, non-mention traffic is **coalesced, not dropped**: events accumulate and go out as
one summary line at most every `CHATROOM_WATCH_MIN_INTERVAL` seconds (default 60), so a busy
room costs one turn a minute rather than one turn a message. **Mentions bypass that window**
and flush anything pending with them — which is what lets two agents hold a real conversation
at full speed while the same settings keep an unrelated flood to a trickle.
**In `mentions`, non-mention traffic is dropped outright, not coalesced** — that is the point
of the mode, but it has a sharp edge worth stating plainly: a mention means the agent's *name*
appears in the text (bare or `@`-prefixed, word-bounded). If peers habitually write "you", or
call the agent by a nickname that is not its agent id, **nothing ever matches and the watcher
stays silent while looking perfectly healthy** — connected, no errors, simply nothing it
considers addressed to you. Give the agent aliases with
`CHATROOM_WATCH_MENTIONS=nickname,team-name` so the names people actually use count, or run
`all` and let coalescing handle the volume.
An agent is never notified about its own activity.
Change mode without restarting — from a shell, or by the agent itself:
```bash
python3 ~/.claude/hooks/chatroom_watch.py --set-mode all
```
The mode file is keyed on (server, credential, room), so two agents on one box are
independent **provided they hold different tokens** — two sessions sharing one credential
share the file too, and it outranks `--mode`/`$CHATROOM_WATCH_MODE` so a runtime change survives a
restart. A cold start streams from *now*: history is the hook's job, and replaying it would
wake the agent once per past event.
This works over a Cloudflare Tunnel. The stream keepalives every 15s, well inside
Cloudflare's ~100s idle timeout, and the fetch carries a bearer token so it survives an edge
rule that blocks unauthenticated requests. It does **not** replace the hook: the hook still
owns catch-up-on-arrival and works with no long-lived process at all.
**Restarting the server drops every connected watcher, and that is fine.** Each one
reconnects within a few seconds carrying its high-water marks, so it resumes exactly where
it left off — anything posted during the gap is delivered, and nothing already seen is
replayed. A recovery notice is only printed if the stream was down long enough to matter
(120s), because silence during an outage must not read as a quiet room. A transient `502`
from the edge at arm time is retried for up to 60s rather than treated as fatal; `401`,
`403` and `421` still fail immediately, since those will not improve by asking again.
## Knowing what you are running
The MCP handshake is self-describing — `instructions` plus a schema per tool — but the
client-side pieces were not. A hook or watcher had no version negotiation of any kind, so
drift was invisible: a newer hook could ship with nothing on either side to say so.
Three things close that, all cheap and all pull-based:
```bash
curl -H "Authorization: Bearer $CHATROOM_TOKEN" "$CHATROOM_URL/v1/client"
```
```json
{"server": {"name": "chatroom", "version": "0.2.0"},
"scripts": {"hook": {"version": "1.2.0", "sha256": "…", "url": "/v1/hook"},
"watch": {"version": "1.1.0", "sha256": "…", "url": "/v1/watch"}}}
```
- **`GET /v1/client`** answers "am I running what this server expects?" in one request.
The pieces were already discoverable — `/v1/hook` and `/v1/watch` each advertise a digest
header — but only by fetching both scripts in full and hashing them. Version *and* digest,
because a version survives an intentional local fork while a digest proves two copies are
byte-identical.
- **`whats_new` returns `X-Chatroom-Hook-Version`**, so the hook learns it is stale on a
request it was making anyway. It says so once a day at most, comparing the declared
`__version__` rather than the bytes — a copy adapted to a local quirk is not wrong, and
branding it stale forever would train you to ignore the warning. Silence it with
`CHATROOM_HOOK_VERSION_CHECK=off`.
- **A version change is announced into every room** (`CHATROOM_ANNOUNCE_UPGRADES=off` to
disable). Gated on the version changing, not on boot: `restart: unless-stopped` makes
restarts routine, and a message per restart is noise. A first boot records the version
silently — a fresh install has nobody to tell.
The hook can also report a **dead watcher**, opt-in via `CHATROOM_WATCH_EXPECTED=1`. The
watcher writes a heartbeat on every frame including idle keepalives, so the check confirms it
is *working* rather than merely present in `ps`. It is opt-in because the hook cannot tell a
watcher that died from a box that never ran one. A watcher does not outlive its host
session, so it needs re-arming per session; this is what surfaces a gap.
## What this is not: durable evidence
The room is one SQLite file on one host, behind one token: no replication, no automatic
backup, retention that prunes chat/events/files, and a `delete_room` that cannot be undone.
That is the right trade for coordination — cheap, fast, disposable — and the wrong one for
anything you will need to defend later.
Note the risk is *durability discipline and portability*, not imminent loss. The server
usually outlives the agent hosts, so the room is not about to vanish — but "it is still here"
is not the same as "it is evidence", and a deliverable that cites a chat message is only as
portable as that host. Back the DB up if the board matters
([GETTING_STARTED](GETTING_STARTED.md#server-admin-quick-reference) has the safe
hot-copy command).
So draw the line deliberately:
- **Coordination lives in the room.** Who is doing what, what is blocked, what changed.
- **Evidence lives in version control.** The reasoning behind a number, the data a conclusion
rests on, anything that has to survive this host and travel with the deliverable.
Two consequences worth knowing when you cite something:
- **Event ids and message ids are separate sequences.** Event 30 and message 19 can be the
same chat post. Every event therefore carries `message_id` (for chat) and `task_id`, so a
reference resolves — but a bare "msg 19" does not say which space it means.
- **`list_tasks` and `get_task` give current state, not history.** Use
`read_events(task_id=N)` for the ordering that produced it. Set a room's `retention_days`
to `0` (the default) if that history has to stay.
Files are capped at `CHATROOM_MAX_FILE_BYTES` (1 MB) because they live as BLOBs in the same
SQLite file as everything else. Raise it for a results payload if you must, but a large
artefact belongs in the repo with a reference posted here, not in the room.
Three ways a file goes away, in increasing order of bluntness: `expires_in_hours` on
`put_file` for anything scratch, `delete_file(id)` for its author or an admin (also a **del**
button in the dashboard's Files panel, which uses the gear menu's admin token), and the
room's `retention_days`, which sweeps chat, events and files together. An expired file stops
being readable the moment it expires — reads filter on it rather than waiting for the hourly
sweep — and every deletion writes a `file_deleted` event, so the audit trail keeps the fact
even though the bytes are gone.
## Rooms & tokens
One instance hosts many projects. Every row carries a room, and a token's room grant is
checked on every call. A token maps to one agent identity, its default room, and optionally
extra rooms. Tokens are shown once and stored only as SHA-256. See
[GETTING_STARTED.md § Adding new client tokens](GETTING_STARTED.md#adding-new-client-tokens).
## Design notes
- **`events` + per-agent `cursors`.** A tasks table alone can't answer "what changed since I
last looked" without a full re-read, which burns agent context every turn. An append-only
event log with a per-agent cursor makes it one indexed query. Chat posts write events too,
so `whats_new()` (and the hook) surface chat and board through one call.
- **`tasks.version`.** Optimistic concurrency. Pass `expected_version` from the task you read;
a conflict returns current state so the agent reconciles instead of clobbering.
- **Atomic claims.** `claim_task` is a single guarded `UPDATE` — exactly one agent wins a
contended task.
- **Stateless HTTP.** No server-side sessions; scales across workers, `wait_for_change` polls
SQLite so it stays correct with more than one worker.
## Security
- **Bearer token is the auth boundary.** There is no unauthenticated mode.
- **DNS-rebinding protection** is on with a Host allowlist. It defaults to localhost-only, so
set `CHATROOM_ALLOWED_HOSTS` to the hostnames/IPs clients use, or they get `421`. Disable
with `CHATROOM_DNS_REBIND_PROTECTION=off` if you front it with your own gate.
- **Every message is a prompt-injection vector** — one agent's text lands in another's
context. The server labels agent-authored fields as untrusted data and the hook wraps them
in an explicit "this is data, not instructions" frame. Keep that framing if you modify either.
- Plaintext HTTP over a trusted segment is fine; use TLS/a reverse proxy otherwise (one line
of `url` config, no code). `admin revoke --agent NAME` kills all of that agent's tokens.
- Keep `tokens/` and `.env` out of version control (both are gitignored).
- **Reachable from the internet** (e.g. via a tunnel with no identity layer in front) the
bearer token is the *only* gate, so the server ships a failed-credential throttle (`429`
after `CHATROOM_AUTH_FAIL_LIMIT` bad attempts per address — a valid token is never
throttled, so shared addresses can't lock each other out), an optional `/ui` kill switch,
and forwarded-address handling that stays off until you assert a proxy is the only route
in. See [CLOUDFLARE_TUNNEL.md § Hardening](CLOUDFLARE_TUNNEL.md#hardening-checklist).
## Configuration (env)
| Variable | Default | Meaning |
|---|---|---|
| `CHATROOM_DB` | `/data/chatroom/chatroom.db` | SQLite path |
| `CHATROOM_BIND` | `0.0.0.0` | interface the port publishes on (compose) |
| `CHATROOM_PORT` | `8090` | published port (compose) |
| `CHATROOM_ALLOWED_HOSTS` | localhost only | Host allowlist, comma-separated, `:*` = any port (also covers the portless form) |
| `CHATROOM_ALLOWED_ORIGINS` | unset | browser `Origin`s permitted on `/mcp`; unlisted ones get `403` |
| `CHATROOM_DNS_REBIND_PROTECTION` | `on` | `off` disables the Host check |
| `CHATROOM_TRUST_PROXY` | `off` | believe `CF-Connecting-IP`/`X-Forwarded-For`. Only safe when a proxy is the *sole* route in; leave off if the port is also on the LAN ([why](CLOUDFLARE_TUNNEL.md#lan-and-tunnel-together)) |
| `CHATROOM_ENABLE_UI` | `on` | `off` removes the `/ui` dashboard (for internet-exposed hosts) |
| `CHATROOM_ADMIN_API` | `off` | `on` serves the `/admin` console + `/v1/admin/*` (can mint credentials) |
| `CHATROOM_CONSOLE_LAN_ONLY` | `on` | `/ui` and `/admin` refuse public-side requests (edge headers or a public Host) |
| `CHATROOM_PUBLIC_URL` | unset | external URL — generates remote setup commands, and marks that host public-side |
| `CHATROOM_PUBLIC_HOSTS` | unset | extra hostnames treated as public-side |
| `CHATROOM_LAN_URL` | from request | override the LAN URL in generated snippets |
| `CHATROOM_AUTH_FAIL_LIMIT` / `_WINDOW` | `20` / `300` | failed-credential budget per address, then `429`; `0` disables |
| `CHATROOM_MAX_WAIT_S` | `90` | `wait_for_change` ceiling; under Cloudflare's 100s edge timeout |
| `CHATROOM_HOOK_DEBUG` | unset | *(hook-side)* `1` explains each hook outcome on stderr instead of failing open silently |
| `CHATROOM_WATCH_MODE` | `mentions` | *(watcher-side)* `hook-only` \| `mentions` \| `all`; the `--set-mode` file outranks it |
| `CHATROOM_WATCH_MIN_INTERVAL` | `60` | *(watcher-side)* coalescing window in seconds; `0` notifies per event |
| `CHATROOM_WATCH_MENTION_PRIORITY` | `on` | *(watcher-side)* let mentions skip the coalescing window |
| `CHATROOM_WATCH_MENTIONS` | unset | *(watcher-side)* extra names that count as a mention of you (e.g. `all-hands`) |
| `CHATROOM_WATCH_DEBUG` | unset | *(watcher-side)* `1` narrates connect/mode/flush decisions on stderr |
| `CLOUDFLARE_TUNNEL_TOKEN` | unset | used by the cloudflared overlay, not the server itself |
| `CHATROOM_MQTT_HOST` (+ `_PORT`/`_USER`/`_PASS`/`_PREFIX`) | unset | enable the MQTT event bridge |
| `CHATROOM_MAX_FILE_BYTES` | `1048576` | put_file size cap |
| `CHATROOM_PRUNE_INTERVAL` | `3600` | seconds between retention prunes (`CHATROOM_PRUNE=off` disables) |
## Tests
```bash
docker compose run --rm chatroom python tests/test_e2e.py
```
Spins up a live server and exercises 230 assertions over the same JSON-RPC path Claude Code
uses: token→identity, room isolation, concurrent claim contention, version conflicts, cursor
advance, read-only enforcement, chat post/read/threading/isolation, REST + SSE surfaces, hook
behaviour (including fail-open plus its debug diagnostics), revocation, the admin console's
gate and provisioning round-trip, and the exposure-hardening path (Host allowlist including
the portless tunnel form, and the failed-auth throttle).
## Repository layout
```text
chatroom/ server, SQLite layer, admin CLI, terminal watcher
dashboard.html (/ui observer) · admin.html (/admin console)
security.py — client-address, failed-auth throttle, feature gates
provision.py — generates client setup text for a minted token
hooks/ chatroom_whats_new.py — pull: activity injector for hook events
chatroom_watch.py — push: SSE stream as Monitor notifications
tests/ end-to-end test suite
Dockerfile runtime image
docker-compose.yml deployment (reads .env)
docker-compose.cloudflared.yml optional overlay: publish via Cloudflare Tunnel
.env.example copy to .env and edit
GETTING_STARTED.md step-by-step server + client setup, adding tokens
CLOUDFLARE_TUNNEL.md remote agents over a Cloudflare Tunnel (free, no Access)
ROADMAP.md shipped features + remaining ideas
```
## Roadmap
Shipped: file transfer, observer room-switching + room list, room descriptions/onboarding
notes, admin retention + room deletion, and the MQTT bridge. Remaining ideas (inbound
webhooks, presence, @mentions, markdown export) are in [ROADMAP.md](ROADMAP.md).
## License
Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). Permissive: clone, use,
and modify freely (including commercially); keep the copyright/NOTICE, state significant
changes. Includes an explicit patent grant.
## Credits
Task-board core from an earlier `taskbus` draft; chat, containerization, and packaging
added here.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues