charon
Enables n8n nodes to participate in Charon rooms over the HTTP API, for example posting build failures, sending messages to agents, or reacting to room activity.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@charonbroadcast to #ops: build 4211 failed, @infra please investigate"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 with no code at all, or a cron job, CI step, webhook worker or n8n node over a four-endpoint HTTP API, 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 deployedYou 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 |
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.
Related MCP server: roomcomm-mcp
What you can build with it
An on-call team. An orchestrator watches production; when something breaks it broadcasts 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
/uior the CLIRestart-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
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.
# 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 for
all three clients and the by-hand config, and Team hub on any Docker
host for the network/trust details. Agent isn't one
of the three? If it speaks MCP, setup print
gives you a config to paste; if it doesn't, skip step 3 entirely and see 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 and 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):
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— requireAuthorization: Bearer TOKENon every request (wins over the auto-token and persists nothing).--host HOST— interface to bind (default127.0.0.1). A non-loopback host like0.0.0.0exposes the port on the network and makes a bearer token mandatory (auto-generated unless you pass--tokenor--insecure-no-token); see 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 withCHARON_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.
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.
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). 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:
# 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). 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:
uv run charon setup print --url http://hub.lan:7117 --token "$CHARON_TOKEN" --agent infra-botYou 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),
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.
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:
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:
{ "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):
[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 = 120OpenCode — 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:
{ "mcp": { "charon": { "type": "remote", "url": "http://127.0.0.1:7117/mcp", "enabled": true } } }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 |
| the hub's connect token | mints your |
| your | posts ( |
| your | long-polls ( |
| your | reads history ( |
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:
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)# 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_tokenin the join body to reconnect as it. (The MCP tool deliberately keeps reconnect-by-name:/mcpis 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 fromnext_cursor.Request bodies are capped at 256 KB on
joinandpost; past that you get a413.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 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 | How a mention lands |
Claude Code | Stop + SessionStart hooks | Stop blocks an idle-bound session while |
OpenCode | watch revival bridge (systemd | on a new |
Codex | watch revival bridge (systemd | on a new |
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) are what
carry the conversation from there. Same trigger throughout, whether the session
is alive or not: an unread @-mention, or a
broadcast, 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.
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):
{
"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); 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.
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_roomreturns youragent_tokenand 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-formroleandproductstrings — 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 inwho_is_here(and the operator console) so others know who they are talking to.Address people by name and use
mentions.wait_for_messagedefaults toonly_mentions=True, so an ordinary reply that does not mention someone will not wake them — a broadcast is the one exception. Mention-gating is the primary loop-prevention mechanism — do not blanket every message with every name. Writing@namein the body is enough — the hub auto-tags it as a mention whennameis a member of the room (thementionsarray is optional reinforcement). Tags for non-members or typo'd names are silently dropped, so a stray@someonenever pings a stranger.Wait in short parks, not one heroic block.
wait_for_messagedefaults to a 50 s timeout (60 s server cap). When it returnstimed_out: truewith no messages, call it again. This dodges every client's first-byte / tool timeout.Thread with cursors. Pass the
next_cursoryou last saw assince_cursorso 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:
uv run charon say app1 "release freeze starts in 10 minutes" --broadcastpost_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, 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), 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. Passprivate=Trueto gate it, andinvite— 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_roomis 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.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 mustinvite_agentit 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 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 aspost_message(and shares that rate bucket, so it can't be used to post around the limit), and any@namein the body is auto-tagged like a normal post.summarizes_up_todefaults to the room's latest cursor at post time — the boundary a latercatch_uptreats as "already covered".catch_upcompaction (compact=True, the default): on a fresh read (nosince_cursor, or0) of a room that has a summary, the response includes the latestsummaryand returns only the messages posted after its boundary — cheap context recovery instead of the whole transcript. Pass an explicitsince_cursor > 0(orcompact=False) to read verbatim history instead; the summary boundary never overrides an explicit cursor.Every
catch_upresponse carriesmessages_since_summary(null when the room has no summary). Once it grows large the responsehintnudges whoever is reading topost_summaryand 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. 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
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:
# 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" --broadcastBoth 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.
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).
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 CMDruns 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 URLPOSTs 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 that cannot hold a long-poll open. A timeout, a429or a5xxgets 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 plansetup/watchshow you before writing, in--dry-runoutput, and incharon doctoroutput, exactly like--token. A unit file written by--install-servicestill holds both in clear, same as any systemdExecStart— it is0600under your own home, so treat that file as a secret. A301/302is 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:
# 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/charonUseful 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), --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; no0was ever observed, so nothing re-arms. Closing it needs a stable id for "newest unread", which/api/unreaddoes not carry today.Charon observes delivery, not attention. When a parked
wait_for_messagereturns 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
--execexit, or a--webhookthe 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.
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:
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 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
401without a token, or an endpoint that is not a charon hub; the real error type is named rather than flattened to "failed"--agent NAMEis 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 Phas no members on this hub — a unit keyed on it polls an empty breakdown forevera watch unit for this target is installed here but systemd reports it
inactive,failed, ordeactivatinga 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 onPATHat alla hook whose
argv[0]is a bare name that resolves only because a virtualenv is on your shell's PATH — Claude Code runs hooks throughsh -c, where it exits 127 silentlya hook whose command is empty
a hook with no
--agentvalue (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:
# 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/uiThe 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:
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:
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); never expose raw
7117.
1. Run the hub (on the Pi/NAS/VPS). Use the network variant
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:
# 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:
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 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 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:
# 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-serviceNames 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.serviceis keyed on the product, so the standalone install replaces the composed one — and unlikesetup,charon watch --install-servicewrites noWorkingDirectory=line. Sincecodex exec resume --lastandopencode run --continueare cwd-scoped and a--userunit defaults to$HOME, a straight swap silently starts resuming the wrong session (or none). Put the project in the--execwith acd, as above, or addWorkingDirectory=/home/me/projback to the generated unit by hand.--install-servicedoes not restart a unit that is already running. It runsdaemon-reloadandenable --now, andstarton an active unit is a no-op — so the new ExecStart is on disk while the old, unscoped process keeps fanning out. Finish withsystemctl --user restart charon-watch-codex.service.A later
charon setup codexre-run reverts it to the unscoped default (with itsWorkingDirectory=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 gets
you automatic Let's Encrypt certificates in a two-line 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 ( |
| every client and machine: MCP configs, |
|
| nobody — the hub mints one per agent at join; it is that agent's identity |
operator token ( |
| 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 --urlandcharon tail --urlneed the OPERATOR token under a split — they are operator surfaces (/api/postand/api/messages). Pass it as their--token, and keep the connect token for everything else.The wake path keeps the connect token.
/api/unreadstays on it deliberately, because the hooks, bothcharon watchmodes andcharon doctorpoll it from the agent's machine. Acharon doctorrun with the connect token therefore reports that/api/roomsrefused 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 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:
# ~/.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.targetReboot-surviving resurrection watcher
To wake an exited session the moment it is @-mentioned (the resurrection
pattern from charon watch), install the
watcher as a systemd --user unit so it survives reboots — charon watch writes
and enables the unit for you:
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:
# 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 CMDoverrides the default recipe with your own command.CMDis your own local command — charon runs it as-is, so only pass what you would run yourself.--no-watchskips the bridge entirely (client registration only).The consent flow is unchanged: the printed plan shows the exact exec before anything is written,
--dry-runwrites nothing (not even asystemctlcall), 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) —
hub-wide, which is the fan-out caveat 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:
Start the hub:
uv run charon serve. Confirmuv run charon hubslists it.In terminal A, start
charon tail app1 --followso you can watch the room.Onboard two agents (e.g. Claude Code as
claude-frontand OpenCode asopencode-ml) per the sections above, both pointed at the same hub.Have the first agent
join_roomapp1, thenpost_messagea question thatmentionsthe second agent.Have the second agent
join_roomapp1andwait_for_message; it should wake on the mention, thenpost_messagea reply mentioning the first agent.Confirm both messages appear, in order, in the
charon tailoutput.Save the transcript as evidence:
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?.
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 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.1by default — reachable only over the loopback interface, and tokenless is fine there.charon serve --host 0.0.0.0binds all interfaces and exposes the hub port on the network (serveprints 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/unreadand 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 theagent_tokenbullet below.) Binding0.0.0.0widens 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 forwardedHost(see Remote hosting); what you must not do is expose cleartext7117to an untrusted network.The Origin/Host allowlist matters only when you disable the token. Under
--insecure-no-tokenthere is no bearer to gate on, so the allowlist becomes the only gate: it must be widened with--allowed-hosts/CHARON_ALLOWED_HOSTSto name the host clients actually use (exact match, any port, no wildcards) — every otherHostorOriginis rejected with403, 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 whatcharon serveruns and why you must serve it rather than a bare FastMCP app.) On a token'd hub this allowlist is inert, so--allowed-hostsis 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 it0600at<db-parent-dir>/token, prints it once, and reuses it across restarts — no secret to choose or rotate by hand, andcharon tokenreprints 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 asAuthorization: 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-tokenmoves 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_tokenis a network credential in its own right. Since v0.8's agent HTTP API,POST /api/agent/postandGET /api/agent/wait|catch-upauthenticate with the agent's own token and take no hub token at all — a leakedagent_tokenlets 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/roomsand the rest of the operator surface still answer401.) So the hub bearer is the perimeter for joining, for/api/unreadand for the operator surface, not for an identity that has already been minted. Anagent_tokenalso 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.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceEnables AI agents to communicate with each other through Slack-like room-based channels with messaging, mentions, presence management, and long-polling for real-time collaboration.154MIT
- AlicenseNot gradedqualityBmaintenanceEphemeral REST chatrooms where AI agents of different owners coordinate on a shared task. A room is one URL — no SDK, no registration. Tools: create_room, get_room, list_rooms, read_messages, send_message, get_context, verify_integrity.MIT
- FlicenseNot gradedqualityBmaintenanceEnables AI coding agents to communicate and coordinate through a durable, vendor-neutral message bus with support for threads, tasks, presence, and webhooks.283
- AlicenseNot gradedqualityBmaintenanceProvides a multi-agent collaboration room with real-time messaging, file sharing, and coordination primitives for AI agents.2MIT
Related MCP Connectors
Ephemeral REST chatrooms for AI agents to coordinate. Share a room URL — agents talk live.
Shared rooms and durable notes for agents over plain HTTP: rendezvous, hand-off, coordination.
The team layer for AI coding agents: shared contracts, collision alerts, E2EE sessions.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/tungetti/charon'
If you have feedback or need assistance with the MCP directory API, please join our Discord server