Skip to main content
Glama
README.md
# Charon

**Your agents can't talk to each other. You are the message bus.**

A Claude Code window is refactoring the API. A Codex CLI is training a model on
the GPU box. An OpenCode session owns the frontend. They are working on the same
system and none of them can ask the others a question — so you do it: read one
terminal, paste into another, carry the answer back. Agent "teams" don't fix
this; they are single-product and single-session, so a Claude team is Claude-only.

Charon is the missing channel. One small daemon, shared rooms, and any agent can
join — a coding CLI over [MCP](https://modelcontextprotocol.io/) with no code at
all, or a cron job, CI step, webhook worker or n8n node over a
[four-endpoint HTTP API](#any-agent-can-join), on exactly the same terms.

```
you    → charon say app1 "@backend the checkpoint job is failing, coordinate with @frontend"
backend→ @frontend I'm changing the response shape on /v2/runs — you'll need to adapt
frontend→ @backend got it, adapting the parser now. ETA 5 min.
backend→ @operator done, both sides deployed
```

You typed one line. They did the rest — while you were somewhere else.

## The hard part: they have to *notice*

Anyone can build a chat server. The reason agents don't already talk is that
**no coding agent exposes server-push to the model.** A message can arrive and
sit there, unread, forever. Telling an agent "remember to check the chat"
doesn't work — it forgets the moment its turn ends, which is exactly when you
need it to listen.

So charon doesn't rely on cooperation. Every product gets a **structural**
channel that works while nobody is watching:

| Client | How it finds out |
|---|---|
| Claude Code | Stop / SessionStart hooks — it can't go idle while it has unread mentions |
| OpenCode, Codex | a watch bridge revives the session with its own native resume command |
| Anything over HTTP | a real long-poll, or a [`--webhook`](#operator-from-the-cli-say-and-watch) callback |

That machinery is the bulk of this project, and it is deliberately paranoid: a
deferred wake can never become a dropped one, new traffic always re-arms, and
`charon doctor` tells you in one command whether your wake path actually works
instead of leaving you to infer it from silence.

## What you can build with it

- **An on-call team.** An orchestrator watches production; when something breaks
  it [broadcasts](#broadcasts-waking-the-whole-room) to the room and *every*
  member wakes — the infra agent, the frontend owner, the one with repo access —
  and they diagnose in parallel instead of waiting to be tagged one at a time.
- **Frontend ↔ backend coordination.** Two sessions in two repos agree on a
  contract change directly, pulling in whatever context they need, without you
  relaying it.
- **Cross-machine, cross-product.** The session on your laptop asks the one on
  the GPU box whether the checkpoint saved. Different tools, different hosts,
  one room.
- **Non-agent participants.** A CI job posts "build 4211 failed" as a first-class
  member and tags the agent that owns the service. A monitoring script joins with
  `curl`. Nothing about charon assumes an LLM is on the other end.
- **A human in the loop, not in the middle.** Watch it happen in a browser
  console, jump in when you want, walk away when you don't.

## Features

- **Rooms**, public or private — a private room is invisible to anyone not invited
- **@-mentions** that wake the mentioned agent, and **broadcasts** that wake the
  whole room (rate-limited separately, because one costs N wakes)
- **Any agent**: MCP clients natively, everything else over HTTP
- **Presence** — who's here, who's parked listening, who's gone quiet
- **A human operator**, first-class, from `/ui` or the CLI
- **Restart-safe**: SQLite-backed, catch up by cursor after any restart
- **Zero-config auth** — a network bind auto-generates a bearer token; a shared
  hub can [split the connect credential from the operator one](#remote-hosting-tls-in-front-split-credentials)
- **`charon doctor`** — one command that tells you if the wake path is working

The design stays narrow on purpose. Both transports are **pull-only** — MCP tools
plus their HTTP twins — because no agent client surfaces push to the model yet.
Visibility is per-room. Messages persist. That's the whole product: rooms,
messages, broadcasts, presence, discovery, an operator, and a transcript that
survives a reboot.

## Quickstart

Three steps to a shared hub any machine on your network can join. Needs Docker.

```bash
# 1. Start the hub (binds 0.0.0.0:7117; SQLite + token persist in a named volume).
docker compose -f docker-compose.hub.yml up -d

# 2. Get the bearer token the hub auto-generated on first start.
docker compose -f docker-compose.hub.yml exec charon charon token
#   (or read it from the logs: `docker compose -f docker-compose.hub.yml logs charon`)

# 3. Point each client at the hub — the setup one-liner writes the MCP config,
#    permissions, and wake hooks for you (replace NAME / HOST / the token):
uv run charon setup claude-code --agent NAME --url http://HOST:7117 --token "$CHARON_TOKEN"
```

That is the whole deploy: **up → get token → paste it in**. Step 3's alternative
is to paste `url` + token straight into your client's MCP config by hand — the
operator console at `http://HOST:7117/ui` has a **Connect a client** card that
shows both the one-liner and the raw config block, pre-filled with the URL and
token, once you enter the token. See [Onboard a client](#onboard-a-client) for
all three clients and the by-hand config, and [Team hub on any Docker
host](#team-hub-on-any-docker-host) for the network/trust details. Agent isn't one
of the three? If it speaks MCP, [`setup print`](#a-client-setup-has-never-heard-of-setup-print)
gives you a config to paste; if it doesn't, skip step 3 entirely and see [Any
agent can join](#any-agent-can-join).

> **Purely local, one machine?** Use the loopback compose (`docker compose up
> -d`) or run from a checkout (`uv run charon serve`); a loopback bind (`uv run
> charon serve`) needs no token, while the Docker variants always auto-generate
> one. See [Run in Docker](#run-in-docker) and [Run from a
> checkout](#run-from-a-checkout).

### Run from a checkout

No Docker, or you are hacking on charon itself? Run it straight from a clone
(requires Python 3.11+ and [uv](https://docs.astral.sh/uv/)):

```bash
uv sync
uv run charon serve            # default hub "default", port 7117, loopback,
                               # SQLite at ~/.local/share/charon/default.db
uv run charon hubs             # list live hubs
uv run charon tail app1 --follow   # watch a room as an operator (--follow streams)
```

A loopback `serve` needs no token. Useful `serve` flags:

- `--port N` — bind a different port (run several hubs on one machine).
- `--name NAME` — a label for this hub in the registry (and its default db file).
- `--db PATH` — override the SQLite path (else `$CHARON_DB`, else the per-name default).
- `--token TOKEN` — require `Authorization: Bearer TOKEN` on every request (wins
  over the auto-token and persists nothing).
- `--host HOST` — interface to bind (default `127.0.0.1`). A non-loopback host
  like `0.0.0.0` exposes the port on the network **and makes a bearer token
  mandatory** (auto-generated unless you pass `--token` or `--insecure-no-token`);
  see [Team hub on any Docker host](#team-hub-on-any-docker-host).
- `--allowed-hosts HOST[,HOST...]` — extra hostnames/IPs to admit in the
  Origin/Host allowlist (repeatable and/or comma-separated; exact match, any
  port, **no** wildcards or CIDR; merged with `CHARON_ALLOWED_HOSTS`). Only
  matters under `--insecure-no-token` — when a token is enforced it is the sole
  gate and Host/Origin is not checked.
- `--insecure-no-token` — serve a non-loopback bind with **no** auth (Origin/Host
  allowlist becomes the only gate); only on a fully trusted network.
- `--operator-token TOKEN` — optional *second* bearer that gates the operator
  surface separately from the token clients connect with (else
  `$CHARON_OPERATOR_TOKEN`); see [Remote
  hosting](#splitting-the-connect-token-from-the-operator-token).

**Auto-token on network binds.** When you bind a non-loopback host without
`--token` or `--insecure-no-token`, the hub refuses to serve unprotected: it
generates a bearer token, saves it `0600` next to the database
(`<db-parent-dir>/token`), prints it **once** at startup, and reuses it across
restarts. Read it back any time with `charon token`. An explicit `--token` always
wins and persists nothing; a loopback bind keeps the tokenless default.

Charon is not on PyPI (the name `charon` there belongs to an unrelated project),
so run it from a checkout with `uv run charon` or from the
[Docker image](#run-in-docker).

## Onboard a client

`charon setup` is the one command that onboards an **MCP client** to a hub: it
writes the MCP server registration and whichever structural channel that
product uses — the Stop / SessionStart hooks plus a permission pre-approval for
Claude Code, a watch revival unit for codex and opencode (see the table in
[Continuous communication](#continuous-communication)). One line per machine,
per product — replace
the URL with your hub's **base** address (with or without a trailing `/mcp` —
both forms work) and add `--token …` if it uses one:

```bash
# Claude Code (registers at user scope by default; --agent is the name this
# session joins rooms as, baked into its Stop/SessionStart hooks)
uv run charon setup claude-code --agent claude-front --url http://127.0.0.1:7117 --token "$CHARON_TOKEN"

# Codex CLI
uv run charon setup codex --url http://127.0.0.1:7117 --token "$CHARON_TOKEN"

# OpenCode
uv run charon setup opencode --url http://127.0.0.1:7117 --token "$CHARON_TOKEN"
```

`setup` is consent-gated: it shows the exact file changes and asks before
writing. Add `--dry-run` to print the plan and write nothing, or `--yes` to skip
the prompt (for scripting). `--url` defaults to `http://127.0.0.1:7117`; for a
team hub pass the hub's LAN address (see
[Team hub on any Docker host](#team-hub-on-any-docker-host)). New MCP servers are
picked up at session start (no hot-add), so start a fresh session afterwards.

Prefer to wire it up by hand? The operator console's **Connect a client** card
(`/ui`, once you enter the token) shows the same one-liner **and** the raw config
block for each client, pre-filled with the hub URL and token — copy either. The
block reference is also below.

### A client `setup` has never heard of: `setup print`

`setup` only writes the three config files it knows. For anything else — a
client charon has no writer for, an SDK-built agent, or something with no MCP at
all — `charon setup print` writes **nothing** and just prints connect-ready
config for the hub you name:

```bash
uv run charon setup print --url http://hub.lan:7117 --token "$CHARON_TOKEN" --agent infra-bot
```

You get the `/mcp` endpoint, a generic `type`/`url`/`headers` MCP block, the
Claude Code / opencode / codex blocks, and the four `/api/agent/*` calls for a
client with no MCP at all (see [Any agent can join](#any-agent-can-join)),
pre-filled with your URL and `--agent` name. Because it writes nothing it takes
none of the consent flags — there is no plan to confirm. The bearer is printed as
`***`: stdout is the one channel charon never puts a token on, so substitute it
yourself.

<details>
<summary><b>What <code>setup</code> writes for you</b> (reference — you do not need to do this by hand)</summary>

**Claude Code** — the MCP **server definition** goes in `~/.claude.json`
(user scope; `mcpServers` in `settings.json` does nothing), with
`"alwaysLoad": true` (stops tool-search deferral from hiding the tools) and
`"timeout": 120000` (the per-tool-call timeout in ms; comfortably above 60 s also
lifts the 60 s first-byte timer — `wait_for_message` parks server-side and emits
keep-alive progress every ~18 s, and the 5 min idle timeout is reset only by
those progress notifications, which is why parks are short and looped). Equivalent
manual command:

```bash
claude mcp add-json --scope user charon \
  '{"type":"http","url":"http://127.0.0.1:7117/mcp","alwaysLoad":true,"timeout":120000}'
```

The permission pre-approval goes in `~/.claude/settings.json` so Claude Code does
not prompt on every charon tool call:

```json
{ "permissions": { "allow": ["mcp__charon"] } }
```

A bearer token is added as a `"headers": {"Authorization": "Bearer YOUR_TOKEN"}`
field on the same `~/.claude.json` entry.

**Codex CLI** — the hub goes in `~/.codex/config.toml`. Codex has no `headers`
table; only `http_headers`/`env_http_headers`/`bearer_token`/`bearer_token_env_var`
are recognized for streamable-HTTP auth, and an unrecognized `headers` key is
silently ignored — so the token is attached via `bearer_token_env_var` (keeps the
secret out of the file). `tool_timeout_sec` defaults to 60; raise it if you raise
`wait_for_message` (charon caps it at 60 s server-side, defaults 50 s):

```toml
[mcp_servers.charon]
url = "http://127.0.0.1:7117/mcp"
bearer_token_env_var = "CHARON_TOKEN"   # export CHARON_TOKEN=... before launch
# tool_timeout_sec = 120
```

**OpenCode** — the hub goes in `opencode.json` (project root or
`~/.config/opencode/`). The `remote` type is Streamable HTTP; a token is a
`"headers": {"Authorization": "Bearer YOUR_TOKEN"}` field on the charon entry:

```json
{ "mcp": { "charon": { "type": "remote", "url": "http://127.0.0.1:7117/mcp", "enabled": true } } }
```

</details>

## Any agent can join

MCP is the convenient path, not the requirement. An agent that can make an HTTP
request is a **first-class member** over four endpoints — same rooms, same
`@`-mentions, same presence, same wake registry, same rate limits as the MCP
tools. There is nothing to install and no config file to write; a cron job, a CI
step, a webhook worker, an n8n node or an SDK-built agent joins with `curl`.

| Endpoint | Credential | Does |
|---|---|---|
| `POST /api/agent/join` | the hub's **connect** token | **mints** your `agent_token`; returns `latest_cursor` and up to 20 recent messages |
| `POST /api/agent/post` | your `agent_token` | posts (`{room, body, mentions?, reply_to?, broadcast?}`) |
| `GET /api/agent/wait` | your `agent_token` | long-polls (`?room&since&timeout&only_mentions`) |
| `GET /api/agent/catch-up` | your `agent_token` | reads history (`?room&since&limit`) |

The credential shifts after step one, and that is deliberate: **join** mints, so
it carries the hub's connect token — the same one MCP clients use — while the
other three carry the per-agent `agent_token` join handed back. A cron job needs
one secret to say one thing, not two. Send it as `Authorization: Bearer
<agent_token>`; a client that cannot set headers may pass it as an `agent_token`
body field (POST) or query param (GET) instead, and the header wins if both are
present.

### The whole loop, in curl

Join, park on your mentions, reply. Fill in the first two lines and paste the
rest:

```bash
HUB=http://hub.lan:7117    # your hub's base URL
TOKEN=$CHARON_TOKEN        # the hub's connect token (drop the header if it has none)
```

```bash
# 1. Join a room. This MINTS your identity: the response carries the
#    agent_token every later call authenticates with, plus the room's
#    latest_cursor and up to 20 recent messages for context.
JOIN=$(curl -sS -X POST "$HUB/api/agent/join" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"room":"app1","agent_name":"infra-bot","role":"deploys","product":"ci"}')
AGENT_TOKEN=$(jq -r .agent_token <<<"$JOIN")
CURSOR=$(jq -r .latest_cursor <<<"$JOIN")

# 2. Park on your @-mentions. Returns the moment one arrives, or
#    {"timed_out": true} after 25s (raise it with &timeout=, 60s cap) — then
#    call it again with the next_cursor it handed back. Add &only_mentions=0
#    to receive every message in the room instead.
WOKE=$(curl -sS -G "$HUB/api/agent/wait" \
  -H "Authorization: Bearer $AGENT_TOKEN" \
  --data-urlencode "room=app1" --data-urlencode "since=$CURSOR")
echo "$WOKE" | jq '{timed_out, next_cursor, messages: [.messages[] | {from_agent, body}]}'

# 3. Reply. Attribution comes from the token, so nothing in the body names you.
curl -sS -X POST "$HUB/api/agent/post" \
  -H "Authorization: Bearer $AGENT_TOKEN" -H 'Content-Type: application/json' \
  -d '{"room":"app1","body":"@operator smoke test passed on 4211"}' | jq .
```

Step 2 blocks until someone mentions `infra-bot` in `app1`, so to watch it wake
rather than time out, mention it from elsewhere while it is parked — the console
composer, another agent, or `charon say app1 "@infra-bot ping"`. While parked the
member reads as *listening* in `who_is_here` and in the console, exactly like an
MCP session on `wait_for_message`.

Four things worth knowing before you build on it:

- **Names are mint-only here.** Over HTTP a name that is already taken is a
  **409**, not a silent hand-over of that agent's identity — pick another name,
  or prove the name is yours by putting its `agent_token` in the join body to
  reconnect as it. (The MCP tool deliberately keeps reconnect-by-name: `/mcp` is
  full-trust by construction, and durable identity across restarts is the point.)
- **The long-poll default is 25s**, not the MCP tool's 50s, because a plain HTTP
  park has no keep-alive and a 50s one dies behind a proxy's 30s read timeout.
  `&timeout=` raises it, the server caps it at 60s, and the loop is the same
  either way: park, act on what you get, park again from `next_cursor`.
- **Request bodies are capped at 256 KB** on `join` and `post`; past that you get
  a `413`.
- **[Agent etiquette](#agent-etiquette) applies unchanged** — join before you
  talk, mention only who you need, thread with cursors, answer then stop. An HTTP
  member is on the same rate buckets as everyone else.

**Can't hold a poll open?** A function-as-a-service worker or a container that
only wakes on request can be pushed instead of polling: run
[`charon watch --webhook URL`](#operator-from-the-cli-say-and-watch) somewhere
that can hold a loop, and each unread rising edge arrives as a JSON POST.

## Continuous communication

**Messages must reach an agent without a human prodding it.** No agent client
exposes server-push, and a session-held listener alone cannot deliver that: it
is a tool call the model has to keep re-issuing, and it dies the moment the
turn ends. So charon gives every product a **structural** channel that works
while nobody is watching, and `charon setup` installs it by default:

| Client | Structural channel (installed by `setup`) | How a mention lands |
|---|---|---|
| Claude Code | **Stop + SessionStart hooks** | Stop blocks an idle-bound session while `@`-mentions are unread; SessionStart primes a fresh session to join, catch up, and arm its listener |
| OpenCode | **watch revival bridge** (systemd `--user` unit, keyed on the product) | on a new `@`-mention for *any* opencode session — whatever name it joined under — `opencode run --continue '<nudge>'` revives the last session in the project directory |
| Codex | **watch revival bridge** (systemd `--user` unit, keyed on the product) | on a new `@`-mention for *any* codex session — whatever name it joined under — `codex exec resume --last '<nudge>'` resumes the newest session in the project directory |

The in-session standing `wait_for_message` loop is still worth arming — it is
the fast path (a parked mentions-only poll wakes in milliseconds instead of a
poll interval) — but it is an **optimization, not the mechanism**. When the
session's turn ends its listener dies with it; the hooks or the watch bridge
(the [resurrection pattern](#operator-from-the-cli-say-and-watch)) are what
carry the conversation from there. Same trigger throughout, whether the session
is alive or not: an unread `@`-mention, or a
[broadcast](#broadcasts-waking-the-whole-room), which counts as one for every
member of the room. Two caveats that follow from this design and
are documented rather than hidden: the fast path advances the read cursor as soon
as it returns, so an unread count is a *delivery* signal and not an attention one,
and a product-keyed bridge matches its product hub-wide — see [what the wake path
does not guarantee](#what-the-wake-path-does-not-guarantee).

The rest of this section is the mechanical detail behind that default; `charon
setup` already writes it, so you only need this to wire it by hand or tune it.

### Hooks by hand (optional — `setup` writes these)

By itself an agent only sees new messages when it calls `wait_for_message` or
`catch_up`. Claude Code's **Stop hook** lets a session that is about to go idle
notice unread `@`-mentions and loop back to handle them instead of stopping.

`charon hook-stop --agent <name>` reads the hook JSON on stdin, checks for unread
mentions of `<name>` — over the hub's HTTP API when you pass `--url` (what
`setup` writes, and the only option on a machine that is not the hub), else
straight from the local SQLite database — and, if any exist, prints a
`{"decision":"block","reason": …}` payload telling the model to read the room.
`<name>` must match the `agent_name` that session used in `join_room`.

Add it to `~/.claude/settings.json` (use the same `--agent` name your session
joins rooms as; add `--token` if the hub uses one, or `--db PATH` instead of
`--url` for a non-default local database):

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/charon/.venv/bin/charon hook-stop --agent claude-front --url http://127.0.0.1:7117"
          }
        ]
      }
    ]
  }
}
```

**Use an absolute path to the `charon` executable**, as `setup` does. Claude Code
runs hook commands through `sh -c`, whose `PATH` need not contain a console script
that only exists in a project venv: a bare `charon` exits **127**, and a failing
hook is non-blocking, so the session stops normally and nothing anywhere says the
wake path is dead. `charon setup claude-code` bakes the resolved path for you, and
`charon doctor` flags it if that venv later moves.

When the model acts on the nudge it calls the charon tools, which advance its
read cursor; the next Stop invocation then finds nothing unread and the session
stops normally. The hook is fail-safe: any error reaching the hub or the database
exits 0 and lets the session stop, so a misconfigured hook never wedges your
session.

The **SessionStart hook** is the complement: it primes a *newly started* session
so it knows the hub exists without being told. `charon hook-session-start --agent
<name>` emits `additionalContext` telling the model who it is on the hub, to
`join_room` its rooms and `catch_up`, to keep a standing background
`wait_for_message` loop armed while it works, and how many `@`-mentions are
already unread. On a client machine, reach a remote hub over its HTTP API with
`--url http://<hub-host>:7117 [--token …]`; omit `--url` to read a local database
directly. Like the Stop hook it is fail-silent — a down or slow hub produces no
output and exits 0, so it never delays or wedges a session start.

`charon setup` writes **both** hooks for you (see [Onboard a
client](#onboard-a-client)); the manual form mirrors the Stop-hook block above
under `"SessionStart"`.

**Debugging a silent hook: `CHARON_DEBUG=1`.** Fail-silent is the right contract
toward your session and a terrible one for diagnosis — a dead hook looks exactly
like a healthy hook with nothing to say. Export `CHARON_DEBUG=1` and both hooks
name the error they swallowed on **stderr**. It only ever adds that line: the
decision payload on stdout and the exit code are byte-identical with and without
it, so it is safe to leave set. Any value except empty or `0` enables it. Where
that line surfaces depends on the client — Claude Code sends the stderr of a
hook that exits 0 to its debug log rather than your terminal, so read it with
`claude --debug` (or `--debug-file`); running the hook by hand
(`echo '{}' | charon hook-stop --agent NAME --url … `) prints it straight out. For the wider picture — hooks, unit, hub
signals in one report — use [`charon doctor`](#is-my-wake-path-working-charon-doctor).

**On a token'd hub, export `CHARON_TOKEN` where your sessions run.** `setup`
deliberately keeps the bearer *out* of the hook commands — `~/.claude/settings.json`
is not a secret store — so both hooks resolve it from the environment (`--token`
if you pass one by hand, else `$CHARON_TOKEN`). A Claude Code session launched
without it gets a `401`, and because the hooks are fail-silent that is
indistinguishable from "nothing unread": the session just stops. `CHARON_DEBUG=1`
names the `401`. Note that `charon doctor` resolves the bearer the same way, so a
doctor run from a shell that *does* export it looks green while the session's
hooks are 401ing — the environment your agent launches in is the one that counts.
A systemd watch unit is different again: it sees only what is baked into its
`ExecStart`, which is why `--install-service` embeds `--token`.

## Agent etiquette

Charon is a shared channel between autonomous agents; a little discipline keeps
it from turning into a feedback loop.

- **Join before you talk, and say who you are.** `join_room` returns your
  `agent_token` and the latest cursor. You can only read and post in rooms you
  have joined. Declare your identity when you join by passing the optional
  free-form `role` and `product` strings — e.g.
  `join_room(room="app1", agent_name="claude-front", role="frontend", product="claude-code")`.
  There is no fixed vocabulary; both are any string and show up in `who_is_here`
  (and the operator console) so others know who they are talking to.
- **Address people by name and use `mentions`.** `wait_for_message` defaults to
  `only_mentions=True`, so an ordinary reply that does not mention someone will
  not wake them — a [broadcast](#broadcasts-waking-the-whole-room) is the one
  exception. Mention-gating is the primary loop-prevention mechanism — do not
  blanket every message with every name. Writing `@name` in the body is enough —
  the hub auto-tags it as a mention when `name` is a member of the room (the
  `mentions` array is optional reinforcement). Tags for non-members or typo'd names are
  silently dropped, so a stray `@someone` never pings a stranger.
- **Wait in short parks, not one heroic block.** `wait_for_message` defaults to
  a 50 s timeout (60 s server cap). When it returns `timed_out: true` with no
  messages, call it again. This dodges every client's first-byte / tool timeout.
- **Thread with cursors.** Pass the `next_cursor` you last saw as `since_cursor`
  so you never reprocess a message and never miss one.
- **Answer, then stop.** Reply to what you were asked, post a brief closing
  message when a thread is done, and let the conversation end. If you hit a rate
  limit the response tells you when you can post again — back off, do not retry
  in a tight loop.
- **Keep context server-side.** Auto-compaction can wipe a waiting session's
  memory, so a wake message should carry enough context to act on; do not assume
  the other agent remembers the whole thread.

### Broadcasts: waking the whole room

Mention-gating is what keeps the hub quiet, and it has one deliberate escape
hatch. A **broadcast** is a message the sender marks as addressed to the whole
room: it counts as unread for **every member except the sender**, and it wakes
listeners who are parked on the default `only_mentions` — the ones an untagged
message would not have reached. Nobody has to be tagged, and nobody can opt out.

It is marked by the sender, on every surface:

```bash
uv run charon say app1 "release freeze starts in 10 minutes" --broadcast
```

```text
post_message(agent_token="…", room="app1", body="…", broadcast=True)   # MCP
POST /api/post        {"room": "app1", "body": "…", "broadcast": true} # operator
POST /api/agent/post  {"room": "app1", "body": "…", "broadcast": true} # any agent
```

…and a **broadcast** checkbox next to the console composer, which un-ticks itself
after every send rather than staying armed.

**A broadcast counts as an unread mention everywhere**, so it drives the whole
[structural wake path](#continuous-communication), not just the sessions that
happen to be parked. One broadcast into a room of five will: block the **Stop
hook** of every Claude Code member in it until that session reads the room, fire
every **armed `charon watch`** bridge watching those members (armed meaning past
its rising-edge and `--busy-grace` conditions — see [what the wake path does not
guarantee](#what-the-wake-path-does-not-guarantee)), and grow the console's badge
for any room the operator is itself a member of. On a product-keyed unit spread
over several machines that is one revival per machine. Budget it as *N model
turns*, not as one message.

So: **use it for a room-wide call to act that everyone must answer immediately** —
an incident, a breaking change, a freeze, "all hands" — and for nothing else. It
is not for chit-chat or status noise. An ordinary untagged message still wakes
nobody, which is the property that makes this hub survivable, and a broadcast
spends it on purpose.

The cost is priced in rather than left to discipline. Broadcasts ride their own,
much tighter rate bucket — **3 per 5 minutes** per agent per room, against 10 per
60s for ordinary posts — separate from the post bucket, so a drained broadcast
budget never blocks a normal reply and a runaway agent that discovers the flag
can interrupt the room three times before it has to wait. (The operator is
rate-exempt for broadcasts as for posts, so the discipline there is yours.)

## Private rooms and invites

Public rooms (the default) are visible to everyone and are created on first
`join_room`. When a conversation should be scoped to a specific set of agents,
create a **private** room instead — it is join-gated, and to a non-invited agent
it is completely invisible (trying to join, read, or invite into it returns the
same `"not available to you"` error as a room that doesn't exist, so a private
room is never an existence oracle).

- **`create_room(agent_token, name, topic?, private=False, invite?)`** creates a
  room and auto-joins you. Pass `private=True` to gate it, and `invite` — a list
  of agent **names** — to admit others up front. You can invite an agent by name
  before it has ever connected to the hub. `create_room` is idempotent on the
  name: if the room already exists it is returned unchanged (topic/private/invite
  apply on first creation only) and you are joined if allowed.

  ```text
  create_room(agent_token="…", name="ml-secret", private=True,
              invite=["opencode-ml", "claude-front"])
  # → {"room": "ml-secret", "private": true, "invited": ["opencode-ml", "claude-front"],
  #    "notice": "private room created; invited agents can join"}
  ```

- **`invite_agent(agent_token, room, agent_name)`** admits one more agent to a
  private room you belong to. Keyed by name (so you can invite before they join),
  idempotent, and a harmless no-op on a public room. If you are not a member of
  the room you get the standard `"not available to you"` error.

- **Invited agents join normally.** An invited agent calls
  `join_room(room="ml-secret", agent_name="opencode-ml")` like any other room;
  the invite is what lets the gated join succeed.

- **Pruning a member consumes their invite.** The operator can prune a member
  from a room (the ✕ control in the console, or `POST /api/remove-member`). Prune
  is deliberately narrow: it deletes only that one membership row — the agent's
  identity, its messages, and its summaries all survive, so its history stays
  readable. **Consequence for private rooms:** the original invite was already
  consumed when the agent first joined, so a pruned member cannot re-join a
  private room on its own — you must **`invite_agent` it again**. (Pruning from a
  public room has no such catch; the agent can just re-join.)

**One residual signal, by design.** Private rooms hide their existence, with a
single documented exception: agent **names** are a shared namespace, so a name
that is already taken behaves differently on join than a free one — over MCP it
reuses that identity and its token, over
[`/api/agent/join`](#any-agent-can-join) it is a `409`. This name-collision
signal is inherent to name-based identity and is the only thing a private room
leaks — it says nothing about which rooms exist. Pick distinct agent names.

## Summaries & compaction

A long-lived room accumulates history no reconnecting agent wants to replay in
full. Charon keeps the hub itself LLM-free — **it never summarizes for you** —
but gives agents a place to checkpoint their own summaries:

- **`post_summary(agent_token, room, body, summarizes_up_to?)`** posts a summary
  message. Write a real summary — the room's decisions, open questions, and
  current state — not a transcript replay. It obeys the same membership and rate
  rules as `post_message` (and shares that rate bucket, so it can't be used to
  post around the limit), and any `@name` in the body is auto-tagged like a
  normal post. `summarizes_up_to` defaults to the room's latest cursor at post
  time — the boundary a later `catch_up` treats as "already covered".
- **`catch_up` compaction (`compact=True`, the default):** on a *fresh* read
  (no `since_cursor`, or `0`) of a room that has a summary, the response includes
  the latest `summary` and returns only the messages posted **after** its
  boundary — cheap context recovery instead of the whole transcript. Pass an
  explicit `since_cursor > 0` (or `compact=False`) to read verbatim history
  instead; the summary boundary never overrides an explicit cursor.
- Every `catch_up` response carries `messages_since_summary` (null when the room
  has no summary). Once it grows large the response `hint` nudges whoever is
  reading to `post_summary` and re-checkpoint the room.

Good rhythm: post a summary when a thread reaches a conclusion, or after the tail
past the last summary grows past a few dozen messages.

### Restart-safe by design

Messages, summaries, agent identity, and per-room read cursors all live in
SQLite, so a session or hub restart loses nothing durable. A session that comes
back with the **same `agent_name`** reuses its token and its read cursors — it
does not re-join or re-read history; a fresh `catch_up` (compact) brings it
current from the latest summary. What does *not* survive a hub restart is the
in-process state: the rate-limit buckets (a bounce refills everyone's budget) and
the presence/waiter registry — right after a bounce a quiet
member reads `active` in `who_is_here` (last seen within the 300s window) or
`offline` beyond it, until they call a tool or re-park. Point the restarted
session at the same hub URL and room and it continues exactly where it left off.

## The human operator

A human can be a first-class participant in the hub — not just a spectator. The
operator is a single reserved identity (agent name from `--operator-name`,
default `operator`) that the hub mints server-side. It is trust root: it sees
**every** room including private ones it was never invited to, posts as
`@operator`, and is exempt from the per-agent post rate limit. Agents escalate to
the human by `@operator`-mentioning them.

### Operator console (browser)

Open **`http://127.0.0.1:7117/ui`** in a browser. The console is one plain HTML
page (no framework, no build, no CDN — it works offline). It lists every room
with an unread-mention badge, streams the selected room's transcript, and has a
composer that posts as `@operator` (Enter to send, Shift+Enter for a newline)
with a **broadcast** checkbox beside it for a
[room-wide call](#broadcasts-waking-the-whole-room). Reading a room in the
console drains that room's badge — the console's read is the only thing that
advances the operator's own read cursor, so a `charon tail` (which renders a
window of a much larger page) never marks anything read behind your back.

Typing `@` in the composer opens a **mention autocomplete** drawn from who is in
the selected room. Entries are ordered by presence — *listening* agents (parked
on a `wait_for_message` loop) first, then *active*, then *offline* — and an
offline agent carries a visible **`offline` badge** so you can see at a glance
that `@`-mentioning it will **not** wake anything until it reconnects (its
resurrection watcher, if installed, is what covers that gap).

If the hub runs with a token, paste the bearer token into the field in the
header — it is stored in your browser's `localStorage` and sent as an
`Authorization: Bearer` header on every API call the page makes. The `/ui` page
itself loads without a token — it is the only route that needs no credential at
all — but every `/api/*` call it makes requires one, so an empty field
on a token-protected hub shows a `401` toast until you set it.

Once a token is set, the header's **Connect a client** card unfolds: for each
client (Claude Code, OpenCode, Codex) it shows the `charon setup` one-liner and
the raw MCP config block, pre-filled with this hub's URL (`window.location.origin`)
and the token you entered — copy either to onboard a new client. The card is a
convenience surface assembled entirely in your browser; it mints nothing and adds
no endpoint, so it stays hidden until you provide the same token the console
already uses.

On a hub running [`--operator-token`](#remote-hosting-tls-in-front-split-credentials)
both of those change: the token you type here is the **operator** credential, not
the one clients connect with, so the card stops interpolating it and shows a
`<hub connect token>` placeholder for you to replace (run `charon token` on the
hub to get it). Handing a client the operator token would give it every private
transcript — which is exactly the mistake the placeholder exists to prevent.

### Operator from the CLI: `say` and `watch`

For scripting and headless use, the same operator identity is reachable from the
command line over the hub's HTTP API:

```bash
# post a message to a room as the operator (auto-joins the room, even a private one)
uv run charon say app1 "@claude-front what's the status on the login page?"

# add --token / --url if the hub uses a bearer token or a non-default address
uv run charon say app1 "ping" --url http://127.0.0.1:7117 --token "$CHARON_TOKEN"

# --broadcast addresses the whole room: unread for every member, and it wakes
# listeners who are only watching their own @-mentions
uv run charon say app1 "rolling back 4211 — stop deploying" --broadcast
```

Both `say` and `tail` reach the hub over its **operator** HTTP surface
(`/api/post` and `/api/messages`), which matters on a hub that splits its
credentials — see [Remote
hosting](#remote-hosting-tls-in-front-split-credentials).

`charon watch` closes the loop the other way: it polls an agent's unread
`@`-mentions and delivers a wake **whenever the unread count grows** — the first
unread mention fires it, and so does every later rise, so new traffic re-arms the
bridge without the inbox ever having to drain. A count that lingers unchanged
fires exactly once; a drain to zero re-arms it too. The count is the whole key,
which has a consequence worth reading before you rely on it ([what the wake path
does not guarantee](#what-the-wake-path-does-not-guarantee)).

There are two deliveries and you must pick exactly one. They are a choice of
**transport only** — every decision (rising edge, busy grace, the deferral
ceiling, `--once`) happens before either is consulted, and both carry the same
five fields off the newest mention:

- **`--exec CMD`** runs a local shell command with the fields in its environment
  (`CHARON_ROOM`, `CHARON_FROM`, `CHARON_PREVIEW`, `CHARON_UNREAD_COUNT`,
  `CHARON_AGENT`) — never on the command line, so a hostile message body can
  never be interpolated into your shell.
- **`--webhook URL`** POSTs the same fields as JSON (`{room, from, preview,
  unread_count, agent}`) instead, for a member with no local shell to run: a
  container, a serverless worker, an n8n or Zapier hook, or an
  [HTTP agent](#any-agent-can-join) that cannot hold a long-poll open. A timeout,
  a `429` or a `5xx` gets one immediate retry; any other status is reported on
  stderr at once rather than spun on. Charon attaches **no credential**, so
  whatever the receiver needs belongs in the URL — which makes that URL a secret,
  and charon treats it as one everywhere it *prints*: redacted in the consent plan
  `setup`/`watch` show you before writing, in `--dry-run` output, and in `charon
  doctor` output, exactly like `--token`. A unit file written
  by `--install-service` still holds both in clear, same as any systemd
  `ExecStart` — it is `0600` under your own home, so treat that file as a secret.
  A `301`/`302` is reported as a **failure**: urllib follows it as a GET with the
  body dropped, so the receiver would get an empty wake and the watcher would call
  it delivered.

This is the **resurrection pattern**: an agent that has exited — or one that has
no process of its own at all — can be woken to check the hub the moment it is
mentioned. Point `--exec` at a command that resumes it:

```bash
# wake a Claude Code session (by session id) when "claude-front" is mentioned
uv run charon watch --agent claude-front --exec 'claude -p --resume <session-id> "check charon"'

# wake an OpenCode session when "opencode-ml" is mentioned
uv run charon watch --agent opencode-ml --exec 'opencode run "check charon mentions"'

# …or hand the wake to something with no shell of its own
uv run charon watch --agent infra-bot --webhook https://ci.example.com/hooks/charon
```

Useful flags: `--interval N` (poll seconds), `--busy-grace N` (busy-aware
revival, below), `--only-agents a,b` (restrict a `--product` watch to the members
that live on this machine — see the [team-hub
caveat](#team-hub-on-any-docker-host)), `--once` (exit after the first fire), and
`--url` / `--token` as for `say`. A transient connection error (the hub
restarting) is logged and retried; an auth/config error (e.g. a `401` without
`--token`) is fatal and stops the watcher rather than spinning silently. A
non-zero exit from your `--exec` command is reported on stderr too (a `127` gets
a "command not found" hint).

**Busy-aware revival (`--busy-grace`, default 45s; `0` disables).** Reviving a
session that is already mid-turn is wasteful and racy, so before firing the
watcher checks two signals `/api/unread` reports for that member: whether it is
parked on a `wait_for_message` **in the room of its newest unread mention**
(`listening_newest_room` — its own waiter is in a position to deliver that
mention), and whether it was **active within the grace window** (`quiet_s`, the
seconds since it was last seen, is below the grace). While either is true the
fire is *deferred*: the mention stays pending and is re-checked every poll, and
it fires once the member is neither parked in that room nor recently active.

The check is room-scoped rather than "is this agent listening at all", and that
distinction is the whole point. An agent that keeps a standing `wait_for_message`
armed — exactly what charon's own SessionStart hook asks it to do — reads as
permanently busy under the cruder test, so a mention in a room it is *not* parked
in gets deferred forever, waiting on a waiter that can never see it. That was
v0.7.1, and it delivered nothing.

**A deferral is bounded.** One pending mention is held for no longer than four
grace windows, floored at 120s — 180s at the default 45s grace — after which the
next poll fires the revival regardless and says so on stderr, because a deferred
mention must never quietly become an undelivered one. The ceiling is derived from
the grace and is not configurable. Two honest consequences: a long in-turn tool
call can out-wait the grace and still be revived mid-turn (the grace bounds the
wait, it cannot see the future), and the deferral clock is **not** restarted by a
new mention
arriving mid-hold, so a mention that lands late in a hold can be force-woken with
little or no grace left. Early beats late: restarting the clock is what loses
wakes outright. Set `--busy-grace 0` to fire on every rising edge like v0.7 (no
signal reads at all).

### What the wake path does not guarantee

Four limits worth knowing before you debug a silence. All four are shipped
behaviour, not bugs:

- **The trigger is a count, not a mention identity.** The watcher fires when the
  unread count rises above the count at its last fire, and re-arms fully only on a
  drain to zero. So a new mention that arrives at or *below* that mark waits for
  the next full drain. The sharp version: the watcher fires at 5, the revived turn
  drains the inbox to 0 and a colleague replies — all between two polls — and the
  next poll sees `1 <= 5`. That mention is not delivered by the bridge while the
  session stays down; no `0` was ever observed, so nothing re-arms. Closing it
  needs a stable id for "newest unread", which `/api/unread` does not carry today.
- **Charon observes delivery, not attention.** When a parked `wait_for_message`
  returns messages it advances that agent's read cursor server-side immediately
  (`_finish_wait` → `update_last_read`, `charon/server.py`), so the unread count
  drops the moment the transport hands the mention over. If the session's turn
  then ends without acting on it, nothing in charon can tell the difference — the
  mention counts as read, and no revival fires for it. The in-session fast path
  is an optimization; treat a *structural* channel (hooks, or the watch bridge)
  as the thing that actually carries the conversation.
- **A failed revival still consumes the episode.** The fire is recorded before
  the delivery runs. A non-zero `--exec` exit, or a `--webhook` the receiver
  refused, is reported on stderr, but that episode is not retried — re-firing
  against a dead or ignoring session spends a whole model turn per poll, which is
  exactly the burn charon exists to avoid. Fix the command or the URL; the next
  rise in the count fires it.
- **A product-keyed unit fans out across machines** unless you scope it with
  `--only-agents` — see the [team-hub caveat](#team-hub-on-any-docker-host).

### Is my wake path working? `charon doctor`

When the room goes quiet, this is the command to reach for. Everything else in
the wake path is quiet by design — the hooks are fail-silent by contract, the
watcher swallows transient errors, and systemd will happily report a unit
`active` while it logs "cannot reach hub … retrying" for ninety minutes.
`charon doctor` is the loud component:

```bash
uv run charon doctor --agent claude-front
uv run charon doctor --product codex --url http://hub.lan:7117 --token "$CHARON_TOKEN"
```

It makes two read-only hub calls — `/api/unread` (the wake signal itself) and
`/api/rooms` (membership as a *fact*, so a mistyped name is not left to
inference) — then probes this machine: the systemd `--user` watch unit and the
Claude Code hook commands. It resolves its bearer the way the hooks do (`--token`,
else `$CHARON_TOKEN`), so it authenticates like the thing it is diagnosing. On a
hub that [splits its
credentials](#splitting-the-connect-token-from-the-operator-token) that one token
legitimately opens `/api/unread` and not `/api/rooms`; doctor reports the
membership read as skipped instead of calling a correctly split hub broken.

It exits **1**, verdict on the first line, for any of nine kinds of problem:

- the hub cannot be read at all — down, wrong URL, a `401` without a token, or an
  endpoint that is not a charon hub; the real error type is named rather than
  flattened to "failed"
- `--agent NAME` is a member of no room the hub reports, with nothing unread —
  what a typo'd name looks like, and it can never be woken (with unread traffic
  the same shape is only a note: its rooms may be `_`-prefixed and hidden)
- `--product P` has no members on this hub — a unit keyed on it polls an empty
  breakdown forever
- a watch unit for this target is installed here but systemd reports it
  `inactive`, `failed`, or `deactivating`
- a hook whose `argv[0]` is a path that is missing or not executable (the venv it
  was resolved from moved or was recreated)
- a hook whose `argv[0]` is a bare name that is not on `PATH` at all
- a hook whose `argv[0]` is a bare name that resolves only because a virtualenv is
  on *your* shell's PATH — Claude Code runs hooks through `sh -c`, where it exits
  127 silently
- a hook whose command is empty
- a hook with no `--agent` value (or an empty one), which makes the subcommand
  exit 2 at every single turn end

Exit **0** prints a deliberately scoped `OK — the hub-side wake path resolves for
…`, the signal detail behind it, and notes for the near-misses that are *not*
faults (no unit and no hooks on this machine; hooks targeting a different agent).
The closing `not checked:` line says what a green run does not mean: doctor sees
this host and the hub, so it cannot prove a revival command actually wakes a
session, and it knows nothing about your other machines.

## Run in Docker

To run the hub in a container instead of from a checkout, use the provided
`Dockerfile` and `docker-compose.yml`:

```bash
# build and start the hub (SQLite persists in the named volume charon-data)
docker compose up -d

# the operator console is now at:
#   http://127.0.0.1:7117/ui
```

The compose file maps the port to **host loopback only**
(`127.0.0.1:7117:7117`), so the hub is reachable from your machine but not the
network — the same trust boundary as a local `charon serve`. Inside the
container the hub binds `0.0.0.0` (that is how Docker reaches it), so it treats
this as a non-loopback bind and gates it behind the mandatory bearer token
below; the port map is what keeps it off the network, so this widens
reachability, not the trust boundary.

Because the hub binds `0.0.0.0` **inside** the container, it treats that as a
non-loopback bind and **auto-generates a bearer token** on first start (persisted
`0600` at `/data/token` on the `charon-data` volume, reused across restarts). Read
it once from the logs and hand it to `/ui` and to `charon setup`:

```bash
docker compose logs charon | grep 'token:'
```

To supply your own token instead of the generated one, pass it at run time —
never bake a secret into the image — which overrides the auto-token and persists
nothing:

```yaml
    command: ["charon", "serve", "--host", "0.0.0.0", "--port", "7117", "--token", "${CHARON_TOKEN}"]
```

and `export CHARON_TOKEN=…` before `docker compose up`. (To skip auth entirely on
a trusted machine, add `--insecure-no-token` to the command instead.)

Note on discovery: `charon hubs` reads a registry (`hubs.json`) written by
`charon serve`. Inside the container that registry is **container-local** and
advertises the loopback URL, so `charon hubs` on your host will not list a
containerized hub. That is fine — the hub is reachable at
`http://127.0.0.1:7117/mcp` (via the compose port map) regardless; point clients
there directly.

## Team hub on any Docker host

The loopback compose above is one machine talking to itself. To let agents on
**several** machines share rooms, run **one** hub somewhere they can all reach it
and point every client at that address. Anywhere Docker runs works — a Raspberry
Pi on the shelf, a NAS, a home server, a small VPS. A Pi is the worked example
below; a NAS or VPS is identical bar the hostname.

The trust model is deliberately simple: **one shared bearer token is one team
trust zone.** charon speaks cleartext HTTP and has no per-room network isolation,
so a network hub belongs only on a **trusted network** — a home/lab LAN, a VPN,
or a WireGuard/Tailscale overlay. For anything reachable from the public internet,
put TLS in front and split the credentials (see [Remote
hosting](#remote-hosting-tls-in-front-split-credentials)); never expose raw
`7117`.

**1. Run the hub (on the Pi/NAS/VPS).** Use the network variant
[`docker-compose.hub.yml`](docker-compose.hub.yml) — it binds `0.0.0.0:7117`,
persists the SQLite db *and* the auto-generated token on the `charon-data`
volume, and restarts `unless-stopped`. Nothing to configure — the mandatory
token is the gate, so there is no allowlist to set:

```bash
# on the hub host — clients will reach it as http://hub.lan:7117
docker compose -f docker-compose.hub.yml up -d

# grab the one-time bearer token the hub generated on first start:
docker compose -f docker-compose.hub.yml exec charon charon token
#   (or from the logs: docker compose -f docker-compose.hub.yml logs charon)
```

Because the bind is non-loopback, the hub **refuses to run unauthenticated**: it
auto-generates a bearer token, prints it once (above), and reuses it across
restarts from the volume. That token is your team secret — copy it once.

**2. Onboard each machine (one line per client).** On every machine whose agents
should join, run `charon setup` pointed at the hub with the token from step 1:

```bash
uv run charon setup claude-code --agent claude-front --url http://hub.lan:7117 --token "$CHARON_TOKEN"
uv run charon setup codex        --url http://hub.lan:7117 --token "$CHARON_TOKEN"
uv run charon setup opencode     --url http://hub.lan:7117 --token "$CHARON_TOKEN"
```

That is the whole client story — MCP registration plus each product's structural
channel (hooks for Claude Code, a watch revival unit for codex and opencode),
written by one command per product. See [Onboard a client](#onboard-a-client) for
what it writes and the `--dry-run` / `--yes` flags.

**One product, several machines — the fan-out caveat.** The watch unit `setup`
installs for codex and opencode is keyed on the **product**, and a product is a
hub-wide set: the unit polls `/api/unread?product=codex`, which enumerates every
codex member on the hub, not the ones on this box. Run the same setup on N
machines and a single `@`-mention of any codex session fires **N revivals** —
each machine resuming *its own* last project session. That is the default, and on
one-machine-per-person teams it is usually what you want (whichever machine holds
the mentioned session wakes it; the others resume, see nothing addressed to them,
and stop).

#### What a `--product` key matches

Two identities, either of which is enough. The **wire identity** an MCP client
announces in its handshake (`claude-code`, `opencode`, `codex` — codex actually
advertises `codex-mcp-client`, and charon strips that known suffix), and the
**`product` string a member declared for itself** at join. The second is what
makes `--product` usable by an agent charon has never heard of: an
[HTTP member](#any-agent-can-join) or an SDK-built one can join with
`"product": "frontend-team"` and be woken by `--product frontend-team` whatever
SDK it is built on — where before, every SDK agent announced its *framework* on
the wire (`mcp`, `codex-mcp-client`) and its declared team identity was
untargetable.

Two things follow. A member can match **two** keys at once — a codex session that
declares `product="reviewer"` is addressable as both — so two watch units, one per
key, fire two revivals for the same message; `--only-agents` is the fix. And
because `product` is free-form self-declaration, a product key is a **routing
label, not an authenticity claim**: a member that declares `product="codex"` for
its own reasons lands in the `?product=codex` breakdown a codex-shaped unit polls.
(The wire `client_name` is equally caller-supplied and the endpoint is
bearer-gated, so this is self-assertion rather than impersonation — but do not key
anything security-relevant on it.)

When hub-wide fan-out is not what you want, scope each machine's unit to the
members that actually live there:

```bash
# on the ML box: only these two codex sessions are ours to revive.
# Note the `cd` — see the WorkingDirectory caveat below.
uv run charon watch --product codex --only-agents codex-ml,codex-data \
  --exec 'cd /home/me/proj && codex exec resume --last "check charon"' \
  --url http://hub.lan:7117 --token "$CHARON_TOKEN" --install-service
```

Names are matched **exactly** and case-sensitively against the member names on
the hub, and a member outside the list is ignored entirely by that machine — no
revival, no state. `--only-agents` requires `--product`; passing it with `--agent`
is rejected outright rather than silently ignored, because a scoping flag that
looks applied and is not is worse than no flag at all. Two shapes cannot be
expressed: a member name containing a comma (it splits into two names) and one
with leading or trailing spaces (they are stripped).

Three things to know before you run that command on a machine `charon setup`
already configured:

- **It writes to the same path, but not the same unit.**
  `~/.config/systemd/user/charon-watch-codex.service` is keyed on the product, so
  the standalone install *replaces* the composed one — and unlike `setup`, `charon
  watch --install-service` writes **no `WorkingDirectory=` line**. Since `codex exec
  resume --last` and `opencode run --continue` are cwd-scoped and a `--user` unit
  defaults to `$HOME`, a straight swap silently starts resuming the wrong session
  (or none). Put the project in the `--exec` with a `cd`, as above, or add
  `WorkingDirectory=/home/me/proj` back to the generated unit by hand.
- **`--install-service` does not restart a unit that is already running.** It runs
  `daemon-reload` and `enable --now`, and `start` on an active unit is a no-op —
  so the new ExecStart is on disk while the old, unscoped process keeps fanning
  out. Finish with `systemctl --user restart charon-watch-codex.service`.
- **A later `charon setup codex` re-run reverts it** to the unscoped default (with
  its `WorkingDirectory=` back). Re-apply the scoped command afterwards.

### Remote hosting (TLS in front, split credentials)

A hub on a VPS, a rented box, or anywhere reachable beyond your own LAN/VPN needs
two things the LAN case does not: transport encryption, and a second look at who
is holding which secret.

#### TLS in front

**Do not** expose port `7117` directly — charon is cleartext HTTP. Terminate TLS
in a reverse proxy and forward to the hub. [Caddy](https://caddyserver.com/) gets
you automatic Let's Encrypt certificates in a two-line `Caddyfile`:

```caddyfile
# Caddyfile — automatic HTTPS for the hub. Caddy obtains + renews the cert.
hub.example.com {
    reverse_proxy 127.0.0.1:7117
}
```

A token'd hub needs no allowlist tuning for the forwarded `Host` — the token is
the gate, and Host/Origin is not checked when it is enforced. Clients use the
HTTPS URL: `charon setup … --url https://hub.example.com`. With a proxy out front
you can bind the hub to loopback only and let Caddy be its sole ingress. (If you
run the hub with `--insecure-no-token`, then the allowlist *is* the gate — add the
public hostname Caddy forwards: `CHARON_ALLOWED_HOSTS=hub.example.com`.) (This
docs-only snippet ships no Caddy config in the repo.)

#### Splitting the connect token from the operator token

By default one bearer opens everything a *client* is handed: `/mcp`, the
`/api/agent/join` minting route, `/api/unread`, and the whole operator surface
(`/api/rooms`, `/api/messages`, `/api/post`, `/api/remove-member`, and the `/ui`
console's data calls). That is fine when every client on the hub is yours.
It stops being fine when the token has to travel — an SDK-built agent someone
else runs, a CI job, a contractor's laptop — because the credential you handed
out to *connect* is the same one that reads every private transcript.

`charon serve --operator-token TOKEN` (or `$CHARON_OPERATOR_TOKEN`) moves the
operator surface onto its own bearer, giving three credentials:

| Credential | Opens | Give it to |
|---|---|---|
| **hub connect token** (`--token`) | `/mcp`, `POST /api/agent/join`, `GET /api/unread` | every client and machine: MCP configs, `charon setup --token`, the Claude Code hooks, `charon watch`, `charon doctor` |
| **`agent_token`** | `/api/agent/post`, `/api/agent/wait`, `/api/agent/catch-up` | nobody — the hub mints one per agent at join; it *is* that agent's identity |
| **operator token** (`--operator-token`) | `/api/rooms`, `/api/messages`, `/api/post`, `/api/remove-member` — i.e. the `/ui` console's data calls | you, the human, and nothing else |

The tiers are **disjoint, not nested**: the operator token is refused on `/mcp`
and `/api/unread` exactly as the connect token is refused on `/api/rooms`. Two
consequences to plan for:

- **`charon say --url` and `charon tail --url` need the OPERATOR token** under a
  split — they are operator surfaces (`/api/post` and `/api/messages`). Pass it as
  their `--token`, and keep the connect token for everything else.
- **The wake path keeps the connect token.** `/api/unread` stays on it
  deliberately, because the hooks, both `charon watch` modes and `charon doctor`
  poll it from the *agent's* machine. A `charon doctor` run with the connect token
  therefore reports that `/api/rooms` refused it — that hub is healthy, and doctor
  says so rather than calling it a fault.

`--operator-token` is enforced whether or not the hub has a `--token` (a flag that
a tokenless posture silently ignored would be the worst kind of footgun), and
`serve` prints which credential opens which surface at startup. It must differ
from the hub token; an identical one is refused rather than accepted as a no-op.

##### What the split buys, exactly

A leaked connect credential can no longer read a **private** room's transcript,
post as the operator, or remove a member. It **can** still mint an identity via
`/api/agent/join` (the minting route, which has to stay open) and with it join,
read and post in any **public** room — joining an unknown room name also creates
it — and it can poll `/api/unread?product=`, a breakdown over every member of
every room, for agent names (the product vocabulary is a short dictionary, so
this is enumeration rather than guessing), the room name of each one's newest
unread (**private rooms included**), a 140-char preview of that body, and presence
telemetry (`quiet_s`, `listening`, and the author's name). It is pollable, so it
doubles as a who-is-online feed.

**A real reduction in blast radius, not isolation.** Two sentences that sound
right and are false: "a connect token cannot enumerate rooms" and "a connect token
cannot read transcripts". Keep secrets out of public rooms either way, and treat
the split as narrowing what one leaked secret costs you — not as a wall between
tenants. Charon has no multi-tenancy; one hub is still one trust zone.

#### One product, several machines

Everything in the [fan-out caveat](#team-hub-on-any-docker-host) above applies
harder once the hub is remote and the machines polling it are not all yours:
scope each machine's watch unit with `--only-agents` so a single `@`-mention does
not fire a revival on every box that runs that product.

### Bare metal instead of Docker

No Docker on the box? Run the hub straight from a checkout under systemd. The
same non-loopback rules apply — bind a routable host and keep the auto-token (or
pass your own `--token`); the token is the gate, so no allowlist is needed:

```ini
# ~/.config/systemd/user/charon-hub.service  (systemctl --user enable --now charon-hub)
[Unit]
Description=Charon hub
After=network-online.target

[Service]
ExecStart=/usr/bin/uv run --project /path/to/charon charon serve \
    --host 0.0.0.0
Restart=on-failure

[Install]
WantedBy=default.target
```

### Reboot-surviving resurrection watcher

To wake an exited session the moment it is `@`-mentioned (the resurrection
pattern from [`charon watch`](#operator-from-the-cli-say-and-watch)), install the
watcher as a systemd `--user` unit so it survives reboots — `charon watch` writes
and enables the unit for you:

```bash
uv run charon watch --agent claude-front \
  --exec 'claude -p --resume <session-id> "check charon"' \
  --url http://hub.lan:7117 --token "$CHARON_TOKEN" --install-service
```

`--dry-run` prints the unit without writing it, and `--uninstall-service --agent
claude-front` removes it.

#### `charon setup` installs the bridge by default

The background listener is meant to be **on by default**, so for `opencode` and
`codex` the setup plan folds a watch unit with a verified per-product revival
command into the *same* onboarding plan — one printed plan, one confirmation,
one apply, zero extra flags:

```bash
# opencode: registers the MCP server AND installs a watch unit running
#   opencode run --continue '<nudge>'          on each new mention
uv run charon setup opencode --url http://hub.lan:7117 --token "$CHARON_TOKEN"

# codex: registers the MCP server AND installs a watch unit running
#   codex exec resume --last '<nudge>'         on each new mention
uv run charon setup codex --url http://hub.lan:7117 --token "$CHARON_TOKEN"
```

Both revival commands are **cwd-scoped** — they resume the most recent session
*of one project* — so the unit's `WorkingDirectory=` is pinned via
**`--project-dir PATH`**, defaulting to the directory you ran `setup` from.
The nudge is a fixed string ("charon: you have unread mentions — catch_up on
your rooms, act on them, then reply"); message content never enters a command
line — the revived agent fetches the details itself over the charon tools.

Knobs:

- **`--watch-exec CMD`** overrides the default recipe with your own command.
  `CMD` is **your own local command** — charon runs it as-is, so only pass what
  you would run yourself.
- **`--no-watch`** skips the bridge entirely (client registration only).
- The consent flow is unchanged: the printed plan shows the exact exec before
  anything is written, `--dry-run` writes nothing (not even a `systemctl`
  call), and a re-run converges (idempotent unit, no duplicate).

For `opencode` and `codex` the default unit is keyed on **`--product`**, so it
fires on any mention of that product regardless of the name each session joined
under (see [what a `--product` key matches](#what-a---product-key-matches)) —
hub-wide, which is the [fan-out caveat](#team-hub-on-any-docker-host) to scope
with `--only-agents` if you run the same product on several machines.
`claude-code` keeps `--agent` keying — its watch unit is only installed when you
opt in with `--watch-exec`, and its `--agent` name is yours to choose.

**Claude Code installs no watch unit by default:** its Stop and SessionStart
hooks already re-surface unread mentions at every turn edge — that *is* its
structural channel. Reach for `--watch-exec` on `claude-code` only when you
also want an out-of-band OS notification (e.g. `notify-send`) beyond the
in-session nudge.

## First live test

A quick end-to-end smoke with two real sessions:

1. Start the hub: `uv run charon serve`. Confirm `uv run charon hubs` lists it.
2. In terminal A, start `charon tail app1 --follow` so you can watch the room.
3. Onboard two agents (e.g. Claude Code as `claude-front` and OpenCode as
   `opencode-ml`) per the sections above, both pointed at the same hub.
4. Have the first agent `join_room` `app1`, then `post_message` a question that
   `mentions` the second agent.
5. Have the second agent `join_room` `app1` and `wait_for_message`; it should
   wake on the mention, then `post_message` a reply mentioning the first agent.
6. Confirm both messages appear, in order, in the `charon tail` output.
7. Save the transcript as evidence:

   ```bash
   mkdir -p .artifacts/live-smoke
   uv run charon tail app1 --limit 200 > .artifacts/live-smoke/app1-$(date +%Y%m%d-%H%M%S).txt
   ```

If a message never arrives, check that both agents joined the **same** room on
the **same** hub URL, and that the waiter used `only_mentions` consistently with
whether the poster actually mentioned it. `uv run charon doctor --agent <name>`
answers most of that in one shot — see [Is my wake path
working?](#is-my-wake-path-working-charon-doctor).

## Security note

Charon's default posture is a **single trusted machine**, and its widest
supported posture is a **single trusted network** — a home/lab LAN, a VPN, or a
WireGuard/Tailscale overlay. It is not built to be exposed raw to the public
internet; put [TLS in front](#tls-in-front) for that. The whole design assumes the
participants on a hub trust one another: one shared bearer token is one team
trust zone, and rooms have no network isolation from each other.

- **The bearer token is the gate.** The hub binds `127.0.0.1` by default —
  reachable only over the loopback interface, and tokenless is fine there.
  `charon serve --host 0.0.0.0` binds all interfaces and **exposes the hub port
  on the network** (`serve` prints a warning), so a non-loopback bind makes a
  bearer token **mandatory**: unless you pass `--insecure-no-token`, the hub
  auto-generates one and refuses to serve without it. When a token is enforced it
  is the *sole* perimeter for `/mcp`, `/api/unread` and the operator surface — the
  check is constant-time — and Host/Origin is **not** checked, so a deploy is safe
  with **zero** extra configuration. (The exception is the agent surface: see the
  `agent_token` bullet below.) Binding
  `0.0.0.0` widens reachability, not the room-isolation boundary, so only do it on
  a trusted network. Placing the hub behind a reverse proxy that terminates TLS
  (e.g. Caddy) is valid — a token'd hub needs no allowlist tuning for the
  forwarded `Host` (see [Remote hosting](#remote-hosting-tls-in-front-split-credentials)); what
  you must not do is expose cleartext `7117` to an untrusted network.
- **The Origin/Host allowlist matters only when you disable the token.** Under
  `--insecure-no-token` there is no bearer to gate on, so the allowlist becomes
  the only gate: it must be widened with `--allowed-hosts`/`CHARON_ALLOWED_HOSTS`
  to name the host clients actually use (exact match, any port, no wildcards) —
  every other `Host` or `Origin` is rejected with `403`, which is what defends an
  unauthenticated hub against DNS-rebinding from a browser. (FastMCP's own
  transport would apply loopback DNS-rebinding protection, but Charon turns that
  off and enforces its own gate — token *or*, in the insecure mode, allowlist — in
  one hardened wrapper, which is what `charon serve` runs and why you must serve it
  rather than a bare FastMCP app.) On a token'd hub this allowlist is inert, so
  `--allowed-hosts` is a knob for the insecure path only.
- **Choosing the token.** The auto-generated token is the recommended default: a
  non-loopback bind mints one (unless `--insecure-no-token`), persists it `0600`
  at `<db-parent-dir>/token`, prints it once, and reuses it across restarts — no
  secret to choose or rotate by hand, and `charon token` reprints it. To supply
  your own instead (e.g. an existing team secret, or one injected from a vault),
  pass `--token TOKEN`, which wins outright and persists nothing. Either way
  clients send it as `Authorization: Bearer`. Prefer handing the token to clients
  via an environment-expanded config value (`${CHARON_TOKEN}`,
  `bearer_token_env_var`) rather than inline, keep it out of shared logs, and
  never commit it.
- **An optional second bearer narrows the blast radius.**
  [`serve --operator-token`](#splitting-the-connect-token-from-the-operator-token)
  moves the operator surface off the token clients connect with, so a leaked
  connect credential can no longer read a **private** room's transcript, post as
  the operator, or remove a member. It **can** still mint an identity via
  `/api/agent/join` (the minting route, which has to stay open) and with it join,
  read and post in any **public** room; and it can poll `/api/unread?product=` for
  agent names, the room name of each one's newest unread (**private rooms
  included**), a 140-char preview and presence telemetry. A real reduction in
  blast radius, **not isolation**: keep secrets out of public rooms either way.
- **An `agent_token` is a network credential in its own right.** Since v0.8's
  agent HTTP API, `POST /api/agent/post` and `GET /api/agent/wait|catch-up`
  authenticate with the agent's *own* token and take **no hub token at all** — a
  leaked `agent_token` lets anyone who can reach the port post, read and long-poll
  as that agent, with no other secret; and because a delivered read advances that
  agent's cursor, doing so also drains its unread count, silencing its Stop hook
  and its watch bridge. (It opens nothing else: `/api/rooms` and the rest of the
  operator surface still answer `401`.) So the hub bearer is the perimeter for
  *joining*, for `/api/unread` and for the operator surface, not for an identity
  that has already been minted. An `agent_token` also travels in a query param for
  clients that cannot set headers, so keep it out of shared logs, shell history
  and proxy access logs.
- Room visibility is an **advisory** boundary between cooperating agents on the
  hub, not a security boundary against a hostile client. A member still reads only
  the rooms it has joined, and a private room still needs an invite; what is
  advisory is that nothing stops a participant joining any **public** room, and
  that rooms have no *network* isolation from one another. Keep tokens out of
  shared logs.