Guidance for Claude Code (claude.ai/code) working in this repository.
Caucus is a supervised message hub letting multiple agents talk to each other (direct, broadcast, or in private channels) while a human operator watches live and can pause/stop the exchange. Agents never use a third-party chat platform — they connect to a local hub over a small HTTP API, and the operator drives everything from a browser console over WebSocket.
The hub is the common denominator — its HTTP API plus the versioned operating protocol it serves. Each agent plugs in the connector that fits its runtime; all connectors speak the same hub:
- Bridge connector (
mcp_bridge.py+watch.py) — for passive, turn-based MCP hosts (interactive Claude Code / Codex / Gemini). The host can't push an inbound message into a running turn, so the bridge relies on an out-of-band watcher process to wake the agent. A constraint adapter, not the ideal shape. - Native connector (
hub_connector.py+ a runtime agent likeclaude_agent.py) — for an autonomous agent that owns its event loop. It listens and speaks in one process and injects inbound messages straight into the live conversation: no watcher, no wake-by-exit. The clean path for bots.
For the per-module breakdown, data flow, routing, and the state machine, see
docs/ARCHITECTURE.md. For what calls what / where is X, prefer the
codegraph index over prose — it never drifts.
# Install (editable, with dev tools)
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
# Run the hub server (serves UI at http://127.0.0.1:8765/)
caucus-hub --host 127.0.0.1 --port 8765 # or: python -m caucus.hub
# Run the MCP bridge (normally launched by the MCP client via .mcp.json, not by hand)
CAUCUS_PROJECT=<name> CAUCUS_HUB_URL=http://127.0.0.1:8765 caucus-bridge
# MCP clients can connect straight to the hub over Streamable HTTP (no caucus-bridge
# subprocess): the hub serves /mcp by default on localhost (--no-mcp-http to disable).
caucus-hub --host 127.0.0.1 --port 8765
# Run the native autonomous Claude connector (needs the `claude` extra +
# Claude Agent SDK auth in the environment). Joins, listens, and replies on
# its own loop — no bridge, no watcher.
uv pip install -e ".[claude]"
CAUCUS_PROJECT=<name> caucus-claude-agent --mission "Negotiate the API with peer-x"
# Lint + types
ruff check src/
mypy src/ # configured strict
# Test: pytest suite under tests/ (unit + integration; asyncio auto mode)
pytest # models, ratelimit, state, hub API, bridge, connector, claude agent
# Legacy standalone smoke test (boots the hub in-process and drives the full
# HTTP flow end to end):
python smoke_test.py # prints "ALL CHECKS PASSED" on successThe tests/ suite mirrors smoke_test.py's coverage but split into focused,
isolated cases. Each API test swaps a fresh HubState onto caucus.hub.state
via the state/client fixtures (the endpoints resolve that global at call
time); the bridge tests run against a real in-thread hub server (live_hub
fixture) because the bridge uses a synchronous httpx.Client.
Four executables and a shared connector library, one package (src/caucus/),
wired by [project.scripts] in pyproject.toml:
hub.py(caucus-hub) — FastAPI app, the only stateful process; HTTP endpoints +/control+/uiWebSocket, serves the operator console at/. Single source of truth for the protocol; a background reaper drops idle peers. Also hosts operator forms (/ask+/forms) — one agent pushes a questionnaire, the operator answers in a console wizard, and the reply routes back to the asker's audience as ananswermessage (see ARCHITECTURE.md).mcp_bridge.py(caucus-bridge) — FastMCP stdio server, one per agent session. Passive untiljoin; tools arm lazily on first use (nosetup).watch.py(caucus-watch) — the no-LLM long-poll listener the bridge launches in the background to wake the passive host on inbound.hub_connector.py— no script; the shared async client library for native connectors. Transport only.claude_agent.py(caucus-claude-agent) — native autonomous Claude connector on the Claude Agent SDK; owns its event loop.mcp_http.py(no script): an in-process Streamable HTTP MCP server the hub mounts at/mcp, on by default for a loopback bind (--no-mcp-httpdisables it), so an MCP client can connect straight to the hub with nocaucus-bridgesubprocess. Same tool surface as the stdio bridge, but the tools are backed byHubConnectorover an in-processhttpx.ASGITransport, so every operator brake is reused, and the per-session token is keyed onMcp-Session-Id.
Full detail (responsibilities, invariants, data flow, state machine, long-poll
contract) lives in docs/ARCHITECTURE.md.
- Long-poll ordering: server poll (
LONG_POLL_SECONDS = 25) < bridge httpx timeout (35s) < client timeout. Invert it and you get spurious disconnects. Keep this in mind when editing/receive. - Bridge stdout is sacred:
mcp_bridge.pylogs to stderr to keep stdout clean for the MCP stdio transport — neverprintto stdout there. - Watcher starts on
join, not on firstsay— a peer may message first; with no watcher running that inbound message is never observed. - State is in-memory only — restarting the hub clears peers and log; all
mutation goes through
HubStateso the FastAPI layer stays thin.
- Full NumPy/Google-style docstrings on modules, classes, and functions (match the existing density).
from __future__ import annotationsat the top of every module; PEP 604 unions (X | None).coloredlogsfor logging (the bridge to stderr — see invariants above).- Python ≥3.10, line length 88,
mypystrict.
Anything that changes behaviour, the HTTP API, the operating protocol, the CLI surface, or dependencies goes through a PR, so CI and review actually see it.
Micro edits may be pushed straight to main, no branch and no PR: a
CHANGELOG.md entry or release cut, a typo, a doc or comment tweak, anything
with no effect on what the package does.
If in doubt, open the PR. The cost of a needless PR is a few minutes; the cost
of an unreviewed behaviour change on main is a broken release.
The package version (SemVer) is derived from git tags by hatch-vcs — it
is never written in the source. At build time hatch-vcs
([tool.hatch.version] source = "vcs") reads the latest vX.Y.Z tag and
writes src/caucus/_version.py (git-ignored build artifact). caucus.__version__
reads that module, falling back to installed package metadata, and hub.py's
FastAPI title uses it. Builds between tags get a dev version like
1.4.1.dev3+g<sha>.
A release is a tag. Merge any number of PRs into main with no version
bump — there is no version field to edit and no chore(release) commit. To
ship, create a GitHub Release vX.Y.Z; the Release workflow
(.github/workflows/release.yml) builds, asserts the built version matches the
tag, publishes to PyPI (Trusted Publishing, environment pypi), and attaches
the artifacts. A PR template plus a PreToolUse hook
(.claude/hooks/pr-version-reminder.sh) remind humans and Claude not to bump
the version in a PR.
Update CHANGELOG.md as you work, never after the fact (mandatory). Every PR
that changes behaviour, the HTTP API, the operating protocol, the CLI surface, or
dependencies MUST add its entry under the [Unreleased] heading in that same PR,
in the right Keep a Changelog bucket (Added / Changed / Fixed / Security).
This is not optional: a merged change with no [Unreleased] line is an incomplete
change, and a reviewer should treat the missing entry as a blocker. There is
exactly one [Unreleased] section and it lives at the top of the file, right
under the intro, never buried mid-history. Cutting a release is then a mechanical
rename: [Unreleased] becomes ## [X.Y.Z](compare/vPREV...vX.Y.Z) (DATE), a fresh
empty [Unreleased] is added back on top, the footer [Unreleased] compare link
is repointed at the new tag, and the GitHub Release vX.Y.Z is created. Never
reconstruct a version's entry from git log at release time, that is how entries get
lost (2.1.0 and 2.2.0 shipped undocumented precisely this way).
One-time setup before the first tag: configure PyPI Trusted Publishing for
caucus-mcp(workflowrelease.yml, environmentpypi) and create thepypiGitHub environment — otherwise the publish step fails.
Note:
PROTOCOL_VERSIONinhub.pyis not the package version — it is an independent counter for the operating-protocol revision. This bump is mandatory: any edit toPROTOCOL_TEXT, however small, must bumpPROTOCOL_VERSION— otherwise connected bridges never learn they are behind and never re-read the protocol on their nextjoin. Conversely, only aPROTOCOL_TEXTchange warrants bumping it.
caucus-protocol.md is a generic, copy-into-any-repo operating protocol (when/how
a given repo's agent should open the caucus, with <this-project> /
<peer-project> placeholders to fill in). It is deployed into peer repos, not
part of the caucus package.